diff --git a/README.md b/README.md index 01d7bd0..de14d5a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Certificates and a private key are stored in a device specific key store. MqttClient.setIdentity({ caCertPem: IOT_CA_CERT, // PEM representation string of a root certificate certPem: IOT_CERT, // PEM representation string of a client certificate - keyPem: IOT_KEY, // PEM representation string of a private key + keyTag: IOT_KEY, // key tag of the private key in Keystore/Keychain keyStoreOptions, // options for a device specific key store. may be omitted }) .then(() => { diff --git a/android/build.gradle b/android/build.gradle index fabb136..2ca7a95 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -29,7 +29,7 @@ android { compileSdkVersion getExtOrIntegerDefault('compileSdkVersion') buildToolsVersion getExtOrDefault('buildToolsVersion') defaultConfig { - minSdkVersion 16 + minSdkVersion 26 targetSdkVersion getExtOrIntegerDefault('targetSdkVersion') versionCode 1 versionName "1.0" diff --git a/android/src/main/java/com/github/emotokcak/reactnative/mqtt/PEMLoader.kt b/android/src/main/java/com/github/emotokcak/reactnative/mqtt/PEMLoader.kt index c444d5a..8a2a104 100644 --- a/android/src/main/java/com/github/emotokcak/reactnative/mqtt/PEMLoader.kt +++ b/android/src/main/java/com/github/emotokcak/reactnative/mqtt/PEMLoader.kt @@ -1,12 +1,7 @@ package com.github.emotokcak.reactnative.mqtt -import java.io.ByteArrayInputStream -import java.security.KeyFactory -import java.security.PrivateKey import java.security.cert.CertificateFactory import java.security.cert.X509Certificate -import java.security.spec.PKCS8EncodedKeySpec -import java.util.Base64 /** Utility to read a certificate and key from a PEM text. */ object PEMLoader { @@ -28,40 +23,6 @@ object PEMLoader { fun loadX509CertificateFromString(pem: String): X509Certificate { val certificateFactory = CertificateFactory.getInstance("X.509") return certificateFactory.generateCertificate(pem.byteInputStream()) - as X509Certificate - } - - /** - * Loads an RSA private key from a given PEM text. - * - * `pem` has to start with "-----BEGIN RSA PRIVATE KEY-----" - * and end with "-----END RSA PRIVATE KEY-----". - * - * @param pem - * - * PEM representation of an RSA private key. - * - * @throws InvalidKeySpecException - * - * If `pem` is invalid. - * - * @throws NoSuchAlgorithmException - * - * If the algorithm "RSA" is not supported. - */ - @JvmStatic - fun loadPrivateKeyFromString(pem: String): PrivateKey { - val isRSA = pem.startsWith("-----BEGIN RSA PRIVATE KEY-----") - val pemContents = pem - .replace("-----BEGIN RSA PRIVATE KEY-----", "") - .replace("-----END RSA PRIVATE KEY-----", "") - .replace("-----BEGIN EC PRIVATE KEY-----", "") - .replace("-----END EC PRIVATE KEY-----", "") - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - val data = Base64.getMimeDecoder().decode(pemContents) - val keySpec = PKCS8EncodedKeySpec(data) - val keyFactory = KeyFactory.getInstance(if (isRSA) "RSA" else "EC") - return keyFactory.generatePrivate(keySpec) + as X509Certificate } } diff --git a/android/src/main/java/com/github/emotokcak/reactnative/mqtt/RNMqttClient.kt b/android/src/main/java/com/github/emotokcak/reactnative/mqtt/RNMqttClient.kt index 0332f60..527586a 100644 --- a/android/src/main/java/com/github/emotokcak/reactnative/mqtt/RNMqttClient.kt +++ b/android/src/main/java/com/github/emotokcak/reactnative/mqtt/RNMqttClient.kt @@ -3,16 +3,10 @@ package com.github.emotokcak.reactnative.mqtt import android.util.Log import com.facebook.react.bridge.* import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter -import javax.net.ssl.SSLSocketFactory import info.mqtt.android.service.MqttAndroidClient import info.mqtt.android.service.MqttTraceHandler -import org.eclipse.paho.client.mqttv3.IMqttActionListener -import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken -import org.eclipse.paho.client.mqttv3.IMqttToken -import org.eclipse.paho.client.mqttv3.MqttCallbackExtended -import org.eclipse.paho.client.mqttv3.MqttConnectOptions -import org.eclipse.paho.client.mqttv3.MqttException -import org.eclipse.paho.client.mqttv3.MqttMessage +import org.eclipse.paho.client.mqttv3.* +import javax.net.ssl.SSLSocketFactory /** * An MQTT client. @@ -22,8 +16,7 @@ import org.eclipse.paho.client.mqttv3.MqttMessage * Powered by [Paho MQTT for Android](https://github.com/eclipse/paho.mqtt.android). */ class RNMqttClient(reactContext: ReactApplicationContext) - : ReactContextBaseJavaModule(reactContext) -{ + : ReactContextBaseJavaModule(reactContext) { companion object { /** Default alias for a root certificate in a key store. */ const val DEFAULT_CA_CERT_ALIAS: String = "ca-certificate" @@ -42,19 +35,19 @@ class RNMqttClient(reactContext: ReactApplicationContext) init { reactContext.addLifecycleEventListener( - object: LifecycleEventListener { - override fun onHostResume() { - Log.d(NAME, "onHostResume") - } + object : LifecycleEventListener { + override fun onHostResume() { + Log.d(NAME, "onHostResume") + } - override fun onHostPause() { - Log.d(NAME, "onHostPause") - } + override fun onHostPause() { + Log.d(NAME, "onHostPause") + } - override fun onHostDestroy() { - Log.d(NAME, "onHostDestroy") + override fun onHostDestroy() { + Log.d(NAME, "onHostDestroy") + } } - } ) } @@ -66,7 +59,7 @@ class RNMqttClient(reactContext: ReactApplicationContext) } catch (e: MqttException) { Log.e(NAME, "failed to disconnect", e) } catch (e: IllegalArgumentException) { - Log.e(NAME, "failed to disconnect", e) + Log.e(NAME, "failed to disconnect", e) } } @@ -78,13 +71,11 @@ class RNMqttClient(reactContext: ReactApplicationContext) * The following key-value pairs have to be specified in `params`. * - `caCertPem`: {`String`} PEM representation of a root CA certificate. * - `certPem`: {`String`} PEM representation of a certificate. - * - `keyPem`: {`String`} PEM representation of a private key. + * - `keyTag`: {`String`} key tag of the private key in Keystore. * - `keyStoreOptions`: {`ReadableMap`} options for a key store. * May have the following optional key-value pairs, * - `caCertAlias`: {`String`} alias for a root certificate. * `DEFAULT_CA_CERT_ALIAS` if omitted. - * - `keyAlias`: {`String`} alias for a private key. - * `DEFAULT_KEY_ALIAS` if omitted. * * If there is already connection to an MQTT broker, this new identity * won't affect it. @@ -101,15 +92,13 @@ class RNMqttClient(reactContext: ReactApplicationContext) fun setIdentity(params: ReadableMap, promise: Promise) { try { val keyStoreOptions: ReadableMap? = - params.getOptionalMap("keyStoreOptions") + params.getOptionalMap("keyStoreOptions") this.socketFactory = SSLSocketFactoryUtil.createSocketFactory( - caCertPem=params.getRequiredString("caCertPem"), - certPem=params.getRequiredString("certPem"), - keyPem=params.getRequiredString("keyPem"), - caCertAlias=keyStoreOptions?.getOptionalString("caCertAlias") ?: - DEFAULT_CA_CERT_ALIAS, - keyAlias=keyStoreOptions?.getOptionalString("keyAlias") ?: - DEFAULT_KEY_ALIAS + caCertPem = params.getRequiredString("caCertPem"), + certPem = params.getRequiredString("certPem"), + keyTag = params.getRequiredString("keyTag"), + caCertAlias = keyStoreOptions?.getOptionalString("caCertAlias") + ?: DEFAULT_CA_CERT_ALIAS ) promise.resolve(null) return @@ -148,25 +137,25 @@ class RNMqttClient(reactContext: ReactApplicationContext) fun loadIdentity(options: ReadableMap?, promise: Promise) { try { this.socketFactory = - SSLSocketFactoryUtil.createSocketFactoryFromAndroidKeyStore() + SSLSocketFactoryUtil.createSocketFactoryFromAndroidKeyStore() promise.resolve(null) return } catch (e: Exception) { Log.e( - NAME, - "failed to load an identity from the Android key store", - e + NAME, + "failed to load an identity from the Android key store", + e ) promise.reject("INVALID_IDENTITY", e) return } catch (e: IllegalArgumentException) { - Log.e( - NAME, - "failed to load an identity from the Android key store", - e - ) - promise.reject("INVALID_IDENTITY", e) - return + Log.e( + NAME, + "failed to load an identity from the Android key store", + e + ) + promise.reject("INVALID_IDENTITY", e) + return } } @@ -194,9 +183,8 @@ class RNMqttClient(reactContext: ReactApplicationContext) fun resetIdentity(options: ReadableMap?, promise: Promise) { try { SSLSocketFactoryUtil.resetAndroidKeyStore( - options?.getOptionalString("caCertAlias") ?: - DEFAULT_CA_CERT_ALIAS, - options?.getOptionalString("keyAlias") ?: DEFAULT_KEY_ALIAS + options?.getOptionalString("caCertAlias") ?: DEFAULT_CA_CERT_ALIAS, + options?.getOptionalString("keyAlias") ?: DEFAULT_KEY_ALIAS ) this.socketFactory = null promise.resolve(null) @@ -238,9 +226,8 @@ class RNMqttClient(reactContext: ReactApplicationContext) fun isIdentityStored(options: ReadableMap?, promise: Promise) { try { val result = SSLSocketFactoryUtil.isIdentityStoredInAndroidKeyStore( - options?.getOptionalString("caCertAlias") ?: - DEFAULT_CA_CERT_ALIAS, - options?.getOptionalString("keyAlias") ?: DEFAULT_KEY_ALIAS + options?.getOptionalString("caCertAlias") ?: DEFAULT_CA_CERT_ALIAS, + options?.getOptionalString("keyAlias") ?: DEFAULT_KEY_ALIAS ) promise.resolve(result) return @@ -288,22 +275,22 @@ class RNMqttClient(reactContext: ReactApplicationContext) val socketFactory = this.socketFactory if (socketFactory == null) { promise.reject( - "ERROR_CONFIG", - Exception("no identity is configured") + "ERROR_CONFIG", + Exception("no identity is configured") ) return } // initializes a client try { val brokerUri = - "$PROTOCOL://${parsedParams.host}:${parsedParams.port}" + "$PROTOCOL://${parsedParams.host}:${parsedParams.port}" val client = MqttAndroidClient( - this.getReactApplicationContext().getBaseContext(), - brokerUri, - parsedParams.clientId + this.getReactApplicationContext().getBaseContext(), + brokerUri, + parsedParams.clientId ) this.client = client - client.setCallback(object: MqttCallbackExtended { + client.setCallback(object : MqttCallbackExtended { override fun connectComplete( reconnect: Boolean, serverURI: String @@ -315,8 +302,8 @@ class RNMqttClient(reactContext: ReactApplicationContext) override fun connectionLost(cause: Throwable?) { Log.d(NAME, "connectionLost", cause) this@RNMqttClient.notifyError("ERROR_CONNECTION", cause) - if(!parsedParams.reconnect) { - this@RNMqttClient.notifyEvent("disconnected", null) + if (!parsedParams.reconnect) { + this@RNMqttClient.notifyEvent("disconnected", null) } } @@ -336,7 +323,7 @@ class RNMqttClient(reactContext: ReactApplicationContext) } }) client.setTraceEnabled(true) - client.setTraceCallback(object: MqttTraceHandler { + client.setTraceCallback(object : MqttTraceHandler { override fun traceDebug(message: String?) { Log.d("$NAME.trace", "$message") } @@ -347,8 +334,7 @@ class RNMqttClient(reactContext: ReactApplicationContext) override fun traceException( message: String?, - e: Exception?) - { + e: Exception?) { Log.e("$NAME.trace", "$message", e) } }) @@ -358,7 +344,7 @@ class RNMqttClient(reactContext: ReactApplicationContext) connectOptions.isAutomaticReconnect = parsedParams.reconnect Log.d(NAME, "connecting to the broker") val token = client.connect(connectOptions) - token.setActionCallback(object: IMqttActionListener { + token.setActionCallback(object : IMqttActionListener { override fun onSuccess(asyncActionToken: IMqttToken) { Log.d(NAME, "connected, token: ${asyncActionToken}") promise.resolve(null) @@ -369,9 +355,9 @@ class RNMqttClient(reactContext: ReactApplicationContext) cause: Throwable? ) { Log.e( - NAME, - "failed to connect, token: ${asyncActionToken}", - cause + NAME, + "failed to connect, token: ${asyncActionToken}", + cause ) promise.reject("ERROR_CONNECTION", cause) } @@ -381,9 +367,9 @@ class RNMqttClient(reactContext: ReactApplicationContext) promise.reject("ERROR_CONNECTION", e) return } catch (e: IllegalArgumentException) { - Log.e(NAME, "failed to connect", e) - promise.reject("ERROR_CONNECTION", e) - return + Log.e(NAME, "failed to connect", e) + promise.reject("ERROR_CONNECTION", e) + return } } @@ -413,9 +399,9 @@ class RNMqttClient(reactContext: ReactApplicationContext) cause: Throwable? ) { Log.e( - NAME, - "failed to disconnect, token: ${asyncActionToken}", - cause + NAME, + "failed to disconnect, token: ${asyncActionToken}", + cause ) this@RNMqttClient.notifyError("ERROR_DISCONNECT", cause) } @@ -424,9 +410,9 @@ class RNMqttClient(reactContext: ReactApplicationContext) Log.e(NAME, "failed to disconnect", e) return } catch (e: IllegalArgumentException) { - // maybe Invalid ClientHandle - Log.e(NAME, "failed to disconnect", e) - return + // maybe Invalid ClientHandle + Log.e(NAME, "failed to disconnect", e) + return } } @@ -456,14 +442,14 @@ class RNMqttClient(reactContext: ReactApplicationContext) } try { - val ints = payload.toArrayList().toArray(Array(payload.size()) {v -> v.toInt()}) - val bytes = ints.foldIndexed(ByteArray(ints.size)) { i, a, v -> a.apply { set(i, v.toByte()) } } - - val token = client.publish( - topic, - bytes, - 1, // qos - false // not retained + val ints = payload.toArrayList().toArray(Array(payload.size()) { v -> v.toInt() }) + val bytes = ints.foldIndexed(ByteArray(ints.size)) { i, a, v -> a.apply { set(i, v.toByte()) } } + + val token = client.publish( + topic, + bytes, + 1, // qos + false // not retained ) token.setActionCallback(object : IMqttActionListener { override fun onSuccess(asyncActionToken: IMqttToken) { @@ -476,9 +462,9 @@ class RNMqttClient(reactContext: ReactApplicationContext) cause: Throwable? ) { Log.e( - NAME, - "failed to publish, token: ${asyncActionToken}", - cause + NAME, + "failed to publish, token: ${asyncActionToken}", + cause ) this@RNMqttClient.notifyError("ERROR_PUBLISH", cause) promise.reject("ERROR_PUBLISH", cause) @@ -516,8 +502,8 @@ class RNMqttClient(reactContext: ReactApplicationContext) } try { val token = client.subscribe( - topic, - 1 // qos + topic, + 1 // qos ) token.setActionCallback(object : IMqttActionListener { override fun onSuccess(asyncActionToken: IMqttToken) { @@ -530,9 +516,9 @@ class RNMqttClient(reactContext: ReactApplicationContext) cause: Throwable? ) { Log.e( - NAME, - "failed to subscribe, token: ${asyncActionToken}", - cause + NAME, + "failed to subscribe, token: ${asyncActionToken}", + cause ) this@RNMqttClient.notifyError("ERROR_SUBSCRIBE", cause) // TODO: iOS may not be able to reject this case @@ -563,27 +549,28 @@ class RNMqttClient(reactContext: ReactApplicationContext) private fun notifyEvent(eventName: String, params: Any?) { Log.d(NAME, "notifying event $eventName") this.getReactApplicationContext() - .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(eventName, params) + .getJSModule(RCTDeviceEventEmitter::class.java) + .emit(eventName, params) } // Parameters for connection. private class ConnectionParameters( - val host: String, - val port: Int, - val clientId: String, - val reconnect: Boolean + val host: String, + val port: Int, + val clientId: String, + val reconnect: Boolean ) { companion object { // Parses a given object from JavaScript. fun parseReadableMap(params: ReadableMap): ConnectionParameters { return ConnectionParameters( - host=params.getRequiredString("host"), - port=params.getRequiredInt("port"), - clientId=params.getRequiredString("clientId"), - reconnect=params.getRequiredBoolean("reconnect") + host = params.getRequiredString("host"), + port = params.getRequiredInt("port"), + clientId = params.getRequiredString("clientId"), + reconnect = params.getRequiredBoolean("reconnect") ) } } } } + diff --git a/android/src/main/java/com/github/emotokcak/reactnative/mqtt/SSLSocketFactoryUtil.kt b/android/src/main/java/com/github/emotokcak/reactnative/mqtt/SSLSocketFactoryUtil.kt index fce4d4c..beb29b9 100644 --- a/android/src/main/java/com/github/emotokcak/reactnative/mqtt/SSLSocketFactoryUtil.kt +++ b/android/src/main/java/com/github/emotokcak/reactnative/mqtt/SSLSocketFactoryUtil.kt @@ -3,8 +3,6 @@ package com.github.emotokcak.reactnative.mqtt import android.util.Log import java.security.KeyStore import java.security.PrivateKey -import java.security.cert.Certificate -import java.security.cert.X509Certificate import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory @@ -16,6 +14,21 @@ object SSLSocketFactoryUtil { private const val PASSWORD: String = "" + /** + * Loads the private key from the Keystore. + * + * @param keyTag + * + * key tag of the private key in Keystore + */ + @JvmStatic + fun loadPrivateKeyFromKeystore(keyTag: String): PrivateKey { + val ks = KeyStore.getInstance("AndroidKeyStore") + ks.load(null) + val entry: KeyStore.Entry = ks.getEntry(keyTag, null) + return (entry as KeyStore.PrivateKeyEntry).privateKey + } + /** * Creates an `SSLSocketFactory` with given certificates. * @@ -27,9 +40,9 @@ object SSLSocketFactoryUtil { * * PEM representation of a certificate. * - * @param keyPem + * @param keyTag * - * PEM representation of a private key. + * key tag of the private key in Keystore. * * @param caCertAlias * @@ -41,7 +54,7 @@ object SSLSocketFactoryUtil { * * @return * - * `SSLSocketFactory` created with `caCertPem`, `certPem` and `keyPem`. + * `SSLSocketFactory` created with `caCertPem`, `certPem` and `keyTag`. * * @throws CertificateException * @@ -61,28 +74,30 @@ object SSLSocketFactoryUtil { fun createSocketFactory( caCertPem: String, certPem: String, - keyPem: String, + keyTag: String, caCertAlias: String, - keyAlias: String ): SSLSocketFactory { // Reference: https://gist.github.com/sharonbn/4104301 val rootCaCert = PEMLoader.loadX509CertificateFromString(caCertPem) val clientCert = PEMLoader.loadX509CertificateFromString(certPem) - val clientKey = PEMLoader.loadPrivateKeyFromString(keyPem) + val clientKey = loadPrivateKeyFromKeystore(keyTag) // certificates and a key saved in the AndroidKeyStore are persisted. // please refer to the following section for AndroidKeyStore, // https://developer.android.com/training/articles/keystore#UsingAndroidKeyStore val androidKeyStore = KeyStore.getInstance("AndroidKeyStore") androidKeyStore.load(null) Log.d( - "SSLSocketFactoryUtil", - "aliases: ${androidKeyStore.aliases().toList()}" + "SSLSocketFactoryUtil", + "aliases: ${androidKeyStore.aliases().toList()}" ) + // Due to a bug with Android 12 https://issuetracker.google.com/issues/197556146?pli=1 + // we need to pass the same keyTag string to the alias parameter + // that we use to load the private key from keystore. androidKeyStore.setKeyEntry( - keyAlias, - clientKey, - PASSWORD.toCharArray(), - arrayOf(clientCert) + keyTag, + clientKey, + PASSWORD.toCharArray(), + arrayOf(clientCert) ) androidKeyStore.setCertificateEntry(caCertAlias, rootCaCert) return this.createSocketFactoryFromKeyStore(androidKeyStore) @@ -135,21 +150,20 @@ object SSLSocketFactoryUtil { * @throws UnrecoverableKeyException */ private fun createSocketFactoryFromKeyStore(keyStore: KeyStore): - SSLSocketFactory - { + SSLSocketFactory { val sslContext = SSLContext.getInstance(SSL_PROTOCOL) val trustManagerFactory = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm() + TrustManagerFactory.getDefaultAlgorithm() ) trustManagerFactory.init(keyStore) val keyManagerFactory = KeyManagerFactory.getInstance( - KeyManagerFactory.getDefaultAlgorithm() + KeyManagerFactory.getDefaultAlgorithm() ) keyManagerFactory.init(keyStore, PASSWORD.toCharArray()) sslContext.init( - keyManagerFactory.getKeyManagers(), - trustManagerFactory.getTrustManagers(), - null // default SecureRandom + keyManagerFactory.getKeyManagers(), + trustManagerFactory.getTrustManagers(), + null // default SecureRandom ) return sslContext.getSocketFactory() } @@ -212,6 +226,7 @@ object SSLSocketFactoryUtil { val keyStore = KeyStore.getInstance("AndroidKeyStore") keyStore.load(null) return keyStore.isCertificateEntry(caCertAlias) && - keyStore.isKeyEntry(keyAlias) + keyStore.isKeyEntry(keyAlias) } } + diff --git a/package.json b/package.json index c9603dc..b908970 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-mqtt-client", - "version": "0.1.4", + "version": "0.2.0", "description": "MQTT client for React Native", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/index.tsx b/src/index.tsx index ac1dcb1..8f3077f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -225,11 +225,11 @@ export type IdentityParameters = { */ certPem: string; /** - * PEM representation of a private key that has signed `certificate`. + * key tag of the private key in Keystore. * - * @member {string} keyPem + * @member {string} keyTag */ - keyPem: string; + keyTag: string; /** * Options for an identity key store. *