Table of Contents

Laborator 9: Implementarea mTLS în aplicația mobilă

În cadrul acestui laborator vom implementa Mutual TLS (mTLS) pentru aplicația Android, asigurând autentificarea bidirecțională între clientul mobil și brokerul MQTT.

Ce este mTLS?

Mutual TLS (mTLS) este un mecanism de autentificare bidirecțională în care:

Acest lucru asigură că:

Diferența față de TLS simplu

Aspect TLS simplu mTLS
Autentificare server DA DA
Autentificare client NU DA
Certificat client necesar NU DA
Protecție împotriva clienților neautorizați NU DA

Fișiere de modificat

Fișier Scop Modificări necesare
broker/mosquitto.conf Configurare broker Activarea mTLS pe portul 8883
MqttHandler.kt Client MQTT Android Configurarea SSLSocketFactory cu certificate client
SetupActivity.kt Ecran de setup Adăugarea opțiunii de selectare a protocolului (TCP/SSL)
MqttConstants.kt Constante Adăugarea constantelor pentru TLS
app/src/main/res/raw/ Resurse Stocarea certificatelor CA și client

Cerințe preliminare

Pasul 1: Generarea certificatelor

1.1 Crearea Autorității de Certificare (CA)

Într-un terminal:

mkdir -p certs && cd certs
 
# Generarea cheii private a CA (4096 biți)
openssl genrsa -out ca.key 4096
 
# Generarea certificatului CA (auto-semnat, valabil 10 ani)
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
    -subj "/C=RO/ST=Romania/L=Bucharest/O=SSProject/OU=Security/CN=SSProject-CA"

1.2 Crearea certificatului de server (pentru brokerul Mosquitto)

Înlocuiți ADRESA_IP_BROKER cu IP-ul real al mașinii pe care rulează Mosquitto:

Într-un terminal:

# Generarea cheii private a serverului
openssl genrsa -out server.key 2048
 
# Generarea cererii de semnare (CSR) cu Subject Alternative Name
openssl req -new -key server.key -out server.csr \
    -subj "/C=RO/ST=Romania/L=Bucharest/O=SSProject/OU=Broker/CN=ADRESA_IP_BROKER" \
    -addext "subjectAltName=IP:ADRESA_IP_BROKER"
 
# Semnarea certificatului de server cu CA (valabil 1 an)
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key \
    -CAcreateserial -out server.crt -sha256 \
    -copy_extensions copyall
 
# Curățare CSR
rm server.csr

1.3 Crearea certificatului de client (pentru aplicația Android)

Într-un terminal:

# Generarea cheii private a clientului
openssl genrsa -out android.key 2048
 
# Generarea cererii de semnare a certificatului de client (CSR)
openssl req -new -key android.key -out android.csr \
    -subj "/C=RO/ST=Romania/L=Bucharest/O=SSProject/OU=MobileClient/CN=android"
 
# Semnarea certificatului de client cu CA (valabil 1 an)
openssl x509 -req -days 365 -in android.csr -CA ca.crt -CAkey ca.key \
    -CAcreateserial -out android.crt -sha256
 
# Curățare CSR
rm android.csr

1.4 Crearea fișierului BKS (BouncyCastle KeyStore) pentru Android

Android nu suportă nativ fișiere PEM. Trebuie să convertim certificatele într-un format compatibil. Vom crea două keystore-uri:

Într-un terminal:

# Crearea unui fișier PKCS12 cu certificatul și cheia clientului
openssl pkcs12 -export -in android.crt -inkey android.key \
    -out android.p12 -name "android-client" \
    -password pass:changeit
 
# Descărcarea BouncyCastle Provider (necesar pentru format BKS)
wget https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk18on/1.80/bcprov-jdk18on-1.80.jar
 
# Crearea truststore-ului BKS (conține CA)
keytool -importcert -v -trustcacerts \
    -file ca.crt -alias ca \
    -keystore truststore.bks \
    -storetype BKS \
    -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider \
    -providerpath ./bcprov-jdk18on-1.80.jar \
    -storepass changeit -noprompt
 
# Crearea keystore-ului BKS (conține certificatul client)
keytool -importkeystore \
    -srckeystore android.p12 -srcstoretype PKCS12 -srcstorepass changeit \
    -destkeystore keystore.bks -deststoretype BKS -deststorepass changeit \
    -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider \
    -providerpath ./bcprov-jdk18on-1.80.jar

1.5 Verificarea certificatelor

Într-un terminal:

# Verificarea certificatului CA
openssl x509 -in ca.crt -text -noout | head -15
 
# Verificarea certificatului de server
openssl verify -CAfile ca.crt server.crt
 
# Verificarea certificatului de client
openssl verify -CAfile ca.crt android.crt
 
# Verificarea conținutului truststore-ului BKS
keytool -list -keystore truststore.bks -storetype BKS \
    -providerclass org.bouncycastle.jce.provider.BouncyCastleProvider \
    -providerpath ./bcprov-jdk18on-1.80.jar \
    -storepass changeit

1.6 Lista fișierelor generate

După generare, directorul certs/ ar trebui să conțină:

certs/
├── ca.crt          # Certificatul CA
├── ca.key          # Cheia privată CA (NU se distribuie!)
├── server.crt      # Certificatul serverului
├── server.key      # Cheia privată a serverului
├── android.crt     # Certificatul clientului Android
├── android.key     # Cheia privată a clientului Android
├── android.p12     # Fișier PKCS12 intermediar
├── truststore.bks  # Truststore BKS → va fi copiat în proiectul Android
└── keystore.bks    # Keystore BKS → va fi copiat în proiectul Android

Pasul 2: Configurarea brokerului Mosquitto cu mTLS

Creați sau editați fișierul mosquitto_mtls.conf:

mosquitto_mtls.conf
# MQTT securizat cu mTLS
listener 8883
cafile ./certs/ca.crt
certfile ./certs/server.crt
keyfile ./certs/server.key
require_certificate true
use_subject_as_username true
allow_anonymous true
 
# Logare
log_type all
connection_messages true
log_timestamp true

Porniți brokerul securizat:

Într-un terminal:

sudo systemctl stop mosquitto.service
mosquitto -c mosquitto_mtls.conf -v

Setări cheie:

Pasul 3: Copierea certificatelor în proiectul Android

Copiați fișierele truststore.bks și keystore.bks în directorul de resurse raw al aplicației:

Într-un terminal:

mkdir -p app/src/main/res/raw
cp certs/truststore.bks app/src/main/res/raw/truststore.bks
cp certs/keystore.bks app/src/main/res/raw/keystore.bks

Directorul res/raw/ este accesibil din cod prin R.raw.truststore și R.raw.keystore.

Fișierele BKS sunt incluse în APK, deci nu trebuie să le distribuiți separat.

Pasul 4: Actualizarea constantelor MQTT

Adăugați constantele necesare pentru TLS în MqttConstants.kt:

MqttConstants.kt
package ro.pub.cs.systems.ssproject.mqtt
 
object MqttConstants {
    const val TAG = "MqttHandler"
    const val TOPIC_IMAGES = "ssproject/images"
    const val TOPIC_COMMANDS = "ssproject/commands"
 
    const val CMD_CAPTURE = "CAPTURE"
    const val CMD_START_LIVE = "START-LIVE"
    const val CMD_STOP_LIVE = "STOP-LIVE"
 
    // TLS
    const val BKS_STORE_TYPE = "BKS"
    const val TLS_PROTOCOL = "TLSv1.2"
    const val KEYSTORE_PASSWORD = "changeit"
    const val TRUSTSTORE_PASSWORD = "changeit"
    const val DEFAULT_TLS_PORT = "8883"
}

Pasul 5: Crearea clasei ''TlsHelper''

Creați o clasă nouă care gestionează crearea contextului SSL:

TlsHelper.kt
package ro.pub.cs.systems.ssproject.mqtt
 
import android.content.Context
import java.security.KeyStore
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
 
object TlsHelper {
 
    /**
     * Creează un SSLSocketFactory configurat pentru mTLS.
     *
     * @param context Contextul Android (necesar pentru accesarea resurselor raw)
     * @param trustStoreResId ID-ul resursei raw pentru truststore (R.raw.truststore)
     * @param keyStoreResId ID-ul resursei raw pentru keystore (R.raw.keystore)
     * @return SSLSocketFactory configurat cu certificatele client și CA
     */
    fun createMtlsSocketFactory(
        context: Context,
        trustStoreResId: Int,
        keyStoreResId: Int
    ): SSLSocketFactory {
        // 1. Încărcarea TrustStore-ului (conține certificatul CA)
        val trustStore = KeyStore.getInstance(MqttConstants.BKS_STORE_TYPE)
        context.resources.openRawResource(trustStoreResId).use { input ->
            trustStore.load(input, MqttConstants.TRUSTSTORE_PASSWORD.toCharArray())
        }
 
        val trustManagerFactory = TrustManagerFactory.getInstance(
            TrustManagerFactory.getDefaultAlgorithm()
        )
        trustManagerFactory.init(trustStore)
 
        // 2. Încărcarea KeyStore-ului (conține certificatul și cheia clientului)
        val keyStore = KeyStore.getInstance(MqttConstants.BKS_STORE_TYPE)
        context.resources.openRawResource(keyStoreResId).use { input ->
            keyStore.load(input, MqttConstants.KEYSTORE_PASSWORD.toCharArray())
        }
 
        val keyManagerFactory = KeyManagerFactory.getInstance(
            KeyManagerFactory.getDefaultAlgorithm()
        )
        keyManagerFactory.init(keyStore, MqttConstants.KEYSTORE_PASSWORD.toCharArray())
 
        // 3. Crearea contextului SSL cu ambele componente (mTLS)
        val sslContext = SSLContext.getInstance(MqttConstants.TLS_PROTOCOL)
        sslContext.init(
            keyManagerFactory.keyManagers,   // Autentificarea clientului
            trustManagerFactory.trustManagers, // Verificarea serverului
            null
        )
 
        return sslContext.socketFactory
    }
}

Explicarea codului:

  1. TrustStore — conține certificatul CA. Este folosit pentru a verifica identitatea serverului (echivalentul ca_certs din Python).
  2. KeyStore — conține certificatul și cheia privată a clientului. Este folosit pentru autentificarea clientului față de server (echivalentul certfile + keyfile din Python).
  3. SSLContext — combină cele două componente într-un context SSL complet pentru mTLS.

Pasul 6: Actualizarea ''MqttHandler'' pentru suportul TLS

Modificați constructorul și metoda connect() din MqttHandler.kt pentru a suporta atât conexiuni simple (TCP), cât și conexiuni securizate (SSL/TLS):

MqttHandler.kt
package ro.pub.cs.systems.ssproject.mqtt
 
import android.os.Build
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.MqttCallback
import org.eclipse.paho.client.mqttv3.MqttClient
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import javax.net.ssl.SSLSocketFactory
 
class MqttHandler(
    brokerIp: String,
    brokerPort: String,
    private val sslSocketFactory: SSLSocketFactory? = null,
    private val isConnectedCallback: (Boolean) -> Unit,
    private val onCommandReceived: (String) -> Unit
) : MqttCallback {
    // Determinăm protocolul pe baza prezenței SSLSocketFactory
    private val protocol = if (sslSocketFactory != null) "ssl" else "tcp"
    private val brokerUrl = "$protocol://$brokerIp:$brokerPort"
    private val clientId = "${Build.MANUFACTURER}_${Build.MODEL}_${MqttClient.generateClientId()}"
    private var client: MqttClient? = null
 
    suspend fun connect() {
        withContext(Dispatchers.IO) {
            if (client?.isConnected == true) {
                return@withContext
            }
 
            try {
                client = MqttClient(brokerUrl, clientId, MemoryPersistence())
                client?.setCallback(this@MqttHandler)
 
                val options = MqttConnectOptions().apply {
                    isCleanSession = true
                    connectionTimeout = 10
                    keepAliveInterval = 60
 
                    // Configurarea TLS dacă SSLSocketFactory este disponibil
                    if (sslSocketFactory != null) {
                        socketFactory = sslSocketFactory
                    }
                }
 
                client?.connect(options)
                Log.i(MqttConstants.TAG, "Connected to $brokerUrl")
 
                client?.subscribe(MqttConstants.TOPIC_COMMANDS, 1)
                Log.i(MqttConstants.TAG, "Subscribed to ${MqttConstants.TOPIC_COMMANDS}")
            } catch (e: Exception) {
                Log.e(MqttConstants.TAG, "Connection error: ${e.message}")
                e.printStackTrace()
            } finally {
                isConnectedCallback(isConnected())
            }
        }
    }
 
    // ... restul metodelor rămân neschimbate (disconnect, isConnected,
    //    publishImage, connectionLost, messageArrived, deliveryComplete)
}

Modificări cheie:

Pasul 7: Actualizarea ''SetupActivity'' pentru selecția protocolului

Modificați ecranul de setup pentru a permite utilizatorului să aleagă între conexiune simplă și conexiune securizată:

7.1 Actualizarea layout-ului

Adăugați un switch pentru activarea TLS în activity_setup.xml, înainte de butonul de conectare:

activity_setup.xml
            <!-- ... după TextInputLayout-ul pentru port ... -->
 
            <com.google.android.material.materialswitch.MaterialSwitch
                android:id="@+id/setup_card_view_tls_switch"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:layout_marginBottom="8dp"
                android:text="Conexiune securizată (mTLS)"
                android:checked="false" />
 
            <com.google.android.material.button.MaterialButton
                android:id="@+id/setup_card_view_connect_btn"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="end|center"
                android:text="@string/setup_connect_btn" />

7.2 Actualizarea ''SetupActivity.kt''

SetupActivity.kt
package ro.pub.cs.systems.ssproject.ui.setup
 
import android.content.Intent
import android.os.Bundle
import android.util.Patterns
import android.widget.Button
import android.widget.EditText
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import com.google.android.material.materialswitch.MaterialSwitch
import ro.pub.cs.systems.ssproject.mqtt.MqttConstants
import ro.pub.cs.systems.ssproject.ui.dashboard.MainActivity
import ro.pub.cs.systems.ssproject.R
 
class SetupActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_setup)
 
        val ipField = findViewById<EditText>(R.id.setup_card_view_ip_field)
        val portField = findViewById<EditText>(R.id.setup_card_view_port_field)
        val tlsSwitch = findViewById<MaterialSwitch>(R.id.setup_card_view_tls_switch)
        val connect = findViewById<Button>(R.id.setup_card_view_connect_btn)
 
        // Actualizarea portului implicit la activarea TLS
        tlsSwitch.setOnCheckedChangeListener { _, isChecked ->
            if (isChecked && portField.text.toString() == "1883") {
                portField.setText(MqttConstants.DEFAULT_TLS_PORT)
            } else if (!isChecked && portField.text.toString() ==
                MqttConstants.DEFAULT_TLS_PORT) {
                portField.setText("1883")
            }
        }
 
        connect.setOnClickListener {
            val inputIp = ipField.text.toString().trim()
            val inputPort = portField.text.toString().trim()
 
            if (inputIp.isEmpty() || !Patterns.IP_ADDRESS.matcher(inputIp).matches()) {
                ipField.error = "Invalid IP address"
                ipField.requestFocus()
                return@setOnClickListener
            }
 
            val portNumber = inputPort.toIntOrNull()
            if (portNumber == null || portNumber !in 1..65535) {
                portField.error = "Invalid port"
                portField.requestFocus()
                return@setOnClickListener
            }
 
            val intent = Intent(this, MainActivity::class.java)
            intent.putExtra("brokerIp", inputIp)
            intent.putExtra("brokerPort", inputPort)
            intent.putExtra("useTls", tlsSwitch.isChecked)
            startActivity(intent)
        }
    }
}

7.3 Actualizarea ''MainActivity.kt''

Modificați crearea MqttHandler în MainActivity pentru a include configurația TLS:

MainActivity.kt
// În metoda onCreate(), înlocuiți blocul de creare a MqttHandler cu:
 
val useTls = intent.getBooleanExtra("useTls", false)
 
val sslSocketFactory = if (useTls) {
    TlsHelper.createMtlsSocketFactory(
        context = this,
        trustStoreResId = R.raw.truststore,
        keyStoreResId = R.raw.keystore
    )
} else {
    null
}
 
mqttHandler = MqttHandler(
    brokerIp,
    brokerPort,
    sslSocketFactory = sslSocketFactory,
    isConnectedCallback = { isConnected ->
        runOnUiThread {
            handleConnectionStateChange(isConnected)
        }
    },
    onCommandReceived = { command ->
        runOnUiThread {
            appendLog(getString(R.string.main_logs_command_received_entry, command))
            handleReceivedCommand(command)
        }
    }
)

Pasul 8: Testarea conexiunii mTLS

mosquitto -c mosquitto_mtls.conf -v
# New connection from X.X.X.X:XXXXX on port 8883.
# New client connected from X.X.X.X:XXXXX as ... (p1, c1, k60, u'android').

Depanare

''SSLHandshakeException: ...'' la conectare

openssl x509 -in android.crt -noout -dates

''Connection refused'' pe portul 8883

netstat -tlnp | grep 8883

''java.io.IOException: Wrong version of key store'' la încărcarea BKS

Emulator Android nu poate accesa broker-ul de pe host

Bune practici de securitate

Cerințe

  1. Urmăriți toți pașii din acest laborator pentru a implementa mTLS în aplicația Android.
  2. Demonstrați că aplicația se conectează cu succes la broker prin mTLS (screenshot cu log-urile brokerului care arată conexiunea securizată).
  3. Verificați cu Wireshark că traficul pe portul 8883 este criptat (comparativ cu portul 1883 din laboratoarele anterioare).
  4. Implementați un mecanism prin care parolele keystore-urilor să nu mai fie hardcodate în cod (de exemplu, folosind BuildConfig cu valori din local.properties).