Communiquez avec votre serveur en utilisant le protocole mTLS dans votre application Android

Quelle que soit la technologie mobile, sécuriser la communication entre son serveur backend et son application mobile est toujours un sujet crucial. Différents protocoles, permettant d’authentifier des utilisateurs ou de chiffrer des communications, existent et s’entrecroisent pour assurer une sécurité maximale.

Récemment, l’application sur laquelle je travaillais était confrontée à une problématique simple : Comment être sûr que je suis bien l’application en question lorsque j’effectue ma requête au serveur, et non pas un Bot ou un utilisateur malveillant ? Dans l’optique justement de pouvoir bloquer les requêtes non-désirées côté serveur, la décision a été prise d’implémenter le protocole mTLS (mutual TLS). L’objectif est de pouvoir authentifier de manière bilatérale, et le serveur côté client (App Android), et le client côté serveur.

Mais alors le mTLS, c’est quoi ?

Et bien c’est relativement simple. C’est tout simplement une évolution du protocole TLS (Transport Layer Security) qui a lui-même remplacé le protocole SSL (Secure Sockets Layer).

Concrètement, TLS (ou plutôt TLS Client Authentication) permet généralement de vérifier la validité d’un serveur et de pouvoir initier une communication chiffrée entre lui et le client. Pour se faire, cela se passe en plusieurs étapes :

  1. Le client contacte le serveur et celui-ci lui présente un certificat censé prouver son identité.
  2. Le client vérifie que le certificat du serveur est valide (il utilise pour cela un Certificate Authority, capable de confirmer ou non le statut du certificat).
  3. Le client et le serveur peuvent alors communiquer via une communication TLS cryptée.

Dans la communication, un Certificate Authority est un tiers qui est capable d’authentifier aux yeux de tout le monde que le certificat présenté par le serveur est bien valide et connu.

Cette explication est bien entendue simplifiée. Pour la version longue, n’hésitez pas à jeter un œil ici. Ce qu’il faut retenir, c’est que TLS est utilisé quasiment partout aujourd’hui. Mais il y a une chose que TLS ne permet pas de faire dans sa configuration initiale, c’est de vérifier l’identité du client. C’est là que mTLS intervient et peut alors faire des merveilles.

Le “m” veut dire “mutual”, cette évolution du protocole impose donc d’effectuer une vérification dans les deux sens, aussi bien pour le client que pour le serveur. Seul l’accord des deux pourra permettre d’initier une communication. Le principe reste le même mais cela rajoute quelques étapes :

  1. Le client contacte le serveur et celui-ci lui présente un certificat censé prouver son identité.
  2. Le client présente son certificat au serveur
  3. Le client vérifie que le certificat du serveur est valide
  4. Le serveur valide le certificat du client et accorde l’accès au client
  5. Le client et le serveur peuvent alors communiquer via une communication TLS chiffrée
Fonctionnement de mTLS
Fonctionnement de mTLS

À la différence du TLS, ici les deux certificats présentés ne sont pas vérifiés par une entité indépendante (une autorité de certification). Les certificats sont signés par un troisième certificat, dit Certificat Racine, lui-même émis par l’organisation ou l’entreprise qui désire mettre en place le protocole entre son client et son serveur. On parle alors de certificat auto-signé.

Et donc sur Android ?

Pour communiquer avec son serveur, le client, en l’occurrence l’application Android, doit avoir accès à deux certificats importants : un certificat permettant d’être lu par le serveur, et une clé privée permettant de lire (et valider) le certificat du serveur. Sur Android, l’essentiel du travail est effectué par le client HTTP. Dans cet exemple, j’ai utilisé le client OkHTTP. C’est l’un des clients les plus répandus aujourd’hui dans les applications.

Récupération des certificats

Il faut d’abord se poser la question de la récupération des deux fichiers. Le plus simple à mettre en oeuvre est de stocker les fichiers dans le dossier Raw du projet et de le récupérer comme ceci :

val certificateContent = resources.openRawResource(R.raw.certificate).bufferedReader().use(BufferedReader::readText)
val privateKeyContent = resources.openRawResource(R.raw.private_key).bufferedReader().use(BufferedReader::readText)

Allez, je le dis tout de suite, il ne faut absolument pas faire ça ! C’est effectivement la manière la plus répandue dans les tutoriels que l’on peut trouver sur internet pour stocker les certificats mTLS, mais ce n'est clairement pas une bonne solution. Pourquoi ? Parce que le stockage de fichiers dans le dossier Raw du projet signifie que les fichiers sont stockés en dur (et non obfusqués) une fois l’application packagée en un apk. Il est ensuite très simple d’ouvrir et de parcourir un apk pour aller chercher ces fichiers. Une personne pourrait alors récupérer les deux fichiers et les utiliser pour communiquer via sa propre application malveillante.

Dans mon cas, je suis un peu coincé et les outils à ma disposition sont limités. Je ne peux pas stocker ça dans mon app, mais je n’ai pas accès à un Vault sécurisé ou à un serveur sur lequel je pourrais télécharger mes fichiers nécessaires.

Stockage du certificat dans un MBaaS

Je me lance donc dans une solution hybride : utiliser un MBaaS (Mobile Backend As A Service) pour stocker et récupérer mes certificats. Il en existe quelques-uns (Google avec Firebase, Amazon avec Amplify ou encore Microsoft avec Azure Mobile pour ne citer que les plus gros) mais Android oblige, et surtout parce que le projet était déjà configuré pour cet outil, je décide de rester dans le giron de Google.

Je ne parlerai pas de l’installation de ces outils avec l’application en question. Cela pourrait faire l’objet d’un article entier et il existe suffisamment de ressources sur internet qui expliquent probablement bien mieux que moi. On peut notamment citer la documentation Google qui est très bien faite.

Il existe différents services au sein de l’écosystème Firebase qui permettent de récupérer de la donnée en temps réel. On peut notamment citer le service Realtime Database qui serait ici bien approprié. Mais parce que notre set d’informations est limité (deux fichiers), et qu’encore une fois le service était déjà configuré dans l’application, j’ai décidé d’utiliser Remote Config. C’est un service permettant de réaliser du paramétrage dynamique de valeurs, offrant même la possibilité de faire de l’A/B Testing et du roll out progressif sur des applications mobiles. Cela se matérialise par un stockage de clés/valeurs. Je me tourne donc vers la création de deux clés : “mtls_certificate” et “mtls_private_key”.

Capture d'écran de la console Firebase Remote Config
Capture d'écran de la console Firebase Remote Config

Une fois ces clés enregistrées, puis publiées, il suffit de les récupérer au sein de l’app via notre client Firebase.

En partant du principe que mon app est déjà configurée pour accueillir un projet Firebase, la première étape est d’importer la bibliothèque Firebase Config permettant de communiquer avec le service Remote Config. Dans votre build.gradle de votre module, deux versions sont possibles, avec et sans BOM :

//Si j’utilise le BOM firebase (ce que je recommande)
implementation platform("com.google.firebase:firebase-bom:$firebase_bom_version")
implementation 'com.google.firebase:firebase-config-ktx'

//Si je ne l’utilise pas (ce que je ne recommande pas)
implementation "com.google.firebase:firebase-config-ktx:$remote_config_version"

Ensuite, il m’a juste fallu utiliser le FirebaseRemoteConfig singleton au lancement de mon app (soit dans l’Application class ou dans mon Activity) comme ceci :

//Le nom de la clé Remote Config associée au certificat
val remoteConfigCertificateKey = "mtls_certificate"
FirebaseRemoteConfig.getInstance().apply {
    setConfigSettingsAsync(
        FirebaseRemoteConfigSettings.Builder()
            .setMinimumFetchIntervalInSeconds(0)
            .build()
    ).onSuccessTask {
        fetchAndActivate()
    }.addOnSuccessListener {
        //Je peux enfin récupérer le contenu de mon certificat
        val certificateContent = getString(remoteConfigCertificateKey)
    }
}
L’instruction setMinimumFetchIntervalInSeconds(0) permet de forcer la synchronisation des données entre le client et le serveur Firebase Remote Config. Cela permet de toujours avoir le dernier certificat entré sur la console Firebase de récupéré sur le mobile.

Sécurisation des appels MBaaS

Je réitère l’opération pour le contenu de ma clé privée et j’ai alors en ma possession toutes les informations nécessaires à transmettre à mon client http. Il me reste néanmoins une chose à faire : protéger la communication avec mon serveur Firebase. Et oui, car les fichiers de configuration et api keys des Google Services sont elles aussi contenues dans le livrable (aab/apk) poussé sur le Play Store. Pour cela, il existe une manière simple de s’assurer que c’est bien mon application du Play Store qui communique avec mon serveur Firebase : Restreindre au niveau de la console Firebase les applications autorisées à communiquer, et ce sur la base de la signature de l’apk.

En effet, Firebase est en mesure d’autoriser un appel ou non en fonction de la signature de l’application qui essaie de communiquer avec lui. Ainsi, on s’assure que seul l’application publiée sur le Play Store, en l'occurrence notre application, peut communiquer avec notre serveur Firebase. Tant que ma clé d’upload pour le Play Store n’est pas compromise, je suis assuré que je suis le seul à pouvoir communiquer avec Remote Config. Pour cela, il suffit d’aller dans les paramètres généraux du projet et de cliquer sur “Ajouter une empreinte”. Pour récupérer la signature de son application, la documentation Google, toujours, est assez simple. Il suffit ensuite de copier coller votre empreinte dans le champ associé (photo ci-dessous), puis de valider et de re-télécharger le fichier google-services.json.

Champ dans la console Firebase pour entrer son empreinte
Champ dans la console Firebase pour entrer son empreinte

Mise en pratique sur un client http OkHTTP

Maintenant que l’on a récupéré nos informations sensibles, il faut pouvoir les fournir au client http. Pour cela, comme je l’ai dit, j’utilise le client OkHTTP. Tout d’abord, mettons la bibliothèque en dépendance Gradle :

implementation "com.squareup.okhttp3:okhttp:4.10.0"

La première chose à faire est de nettoyer le contenu de la clé et du certificat. Pour ça j’ai créé deux méthodes simples :

private fun trimCertificateKey(keyContent: String) =
    keyContent
        .replace("-----BEGIN PRIVATE KEY-----", "")
        .replace("-----END PRIVATE KEY-----", "")
        .replace(System.lineSeparator(), "")
        .replace(" ", "")

private fun trimCertificate(certificateContent: String) =
    certificateContent
        .replace("-----BEGIN CERTIFICATE-----", "")
        .replace("-----END CERTIFICATE-----", "")
        .replace(System.lineSeparator(), "")
        .replace(" ", "")

Pour pouvoir fournir les deux fichiers au client http, il faut passer par plusieurs étapes.

1- D’abord, il faut générer un objet Certificate pour le certificat public et un objet KeySpec qui permettra d’indiquer quel type de clé privée est utilisée pour le client.

fun getCertificateKeySpec(privateKey: String): KeySpec =
    PKCS8EncodedKeySpec(
        Base64
            .getDecoder()
            .decode(trimCertificateKey(privateKey))
    )

fun getCertificate(certificateContent: String): Certificate =
    CertificateFactory.getInstance("X.509").generateCertificate(
        ByteArrayInputStream(
            Base64
                .getDecoder()
                .decode(trimCertificate(certificateContent))
        )
    )

2- Ensuite, il faut récupérer une instance du Keystore propre au téléphone Android, dans lequel je vais venir stocker ma clé privée et mon certificat dans ce keystore, via un alias et un secret généré. Ce Keystore sera utilisé pour stocker nos informations liées au client et au serveur, respectivement matérialisées par l’utilisation de KeyManager et de TrustManager dont je décris le comportement plus bas.

fun getKeyStore(keySpec: KeySpec, certificate: Certificate): KeyStore =
    KeyStore.getInstance(KeyStore.getDefaultType()).apply {
        load(null, SECRET.toCharArray())
        setKeyEntry(
            CLIENT_ALIAS,
            KeyFactory.getInstance("RSA").generatePrivate(keySpec),
            SECRET.toCharArray(),
            arrayOf(certificate)
        )
    }
Il existe un article très intéressant qui permet d’expliquer comment fonctionne un Keystore sous Java. Du même auteur, plus de détails sur le fonctionnement des KeyManager est donné sur cet article.

3- Puis, il faut créer un tableau de KeyManager via une factory de type KeyManagerFactory que l’on va initialiser avec notre keystore. C’est ce tableau de KeyManager qui est indispensable à fournir à la SSLSocketFactory pour retrouver la clé et le certificat. En effet, un KeyManager peut être considéré comme un conteneur de clés privées. C’est notamment ce tableau de KeyManager qui sera utilisé pour valider le certificat du client.

fun getKeyManagers(keystore: KeyStore): Array<KeyManager> =
    KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
        init(keystore, SECRET.toCharArray())
    }.keyManagers

4- Il faut également créer un tableau de TrustManager via un TrustManagerFactory qui permettra de fournir les informations nécessaires pour vérifier la validité du certificat (en lieu et place de l’autorité de Certification). Il faut également le fournir à la SSLSocketFactory. Un TrustManager est l’objet qui est amené à recevoir les informations du serveur dans l’optique de les valider ou non.

fun getTrustManagers(): Array<TrustManager> =
    TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
        init(null as KeyStore?) 
    }.trustManagers
init(null as KeyStore?) peut paraître étrange comme instruction mais elle est indispensable. Elle sert à initier notre TrustManagerFactory avec un Keystore null (et par conséquent vide). Ce Keystore est utilisé comme source originelle pour l’autorité de Certification. Si celle-ci est null, on passe dans le cas de figure d’un certificat vérifié par le certificat racine propre à notre système (voir partie Mais le mtls c’est quoi ?) ce qui est le comportement que l’on cherche à obtenir.

5- Enfin, il faut créer cette SSLSocketFactory qui sera fournie à mon client HTTP. Cette factory est initialisée avec les deux managers cités juste au-dessus.

fun getSslSocketFactory(
    keyManagers: Array<KeyManager>,
    trustManagers: Array<TrustManager>,
): SSLSocketFactory = SSLContext.getInstance("TLS").apply {
    init(keyManagers, trustManagers, SecureRandom())
}.socketFactory

6- Une fois tout ça créé, il suffit de d’attacher notre SSLSocketFactory à notre client ce qui donne derrière le code complet suivant :

val certificate = getCertificate(certificateContent)
val privateKeySpec = getCertificateKeySpec(privateKey)
val keystore = getKeyStore(privateKeySpec, certificate)
val keyManagers = getKeyManagers(keystore)
val trustManagers = getTrustManagers()
val sslSocketFactory = getSslSocketFactory(keyManagers, trustManagers)

//Le client http peut maintenant être utilisé pour réaliser des appels
val okhttpClient = OkHttpClient.Builder()
    .sslSocketFactory(sslSocketFactory, trustManagers.first() as X509TrustManager)
    .build()
Lors de l’assignation d’une SSLSocketFactory, il est nécessaire de fournir le TrustManager qui sera utilisé pour servir d’Autorité de Certification. On utilise alors la première entrée du tableau de TrustManager que l’on est obligé de caster en objet X509TrustManager. En effet, le format X509 est le format utilisé pour les certificats issus par une autorité de Certification.

Et voilà ! Maintenant, il est possible de contacter son serveur en utilisant le protocole mTLS.

Vraiment une solution miracle ?

Dans notre cas, pas vraiment. Comme démontré, il est tout à fait possible d’embarquer cette technologie sur des applications mobiles si cela nous est imposé par l’équipe technique ou si aucune autre alternative au sein de notre Système d’Informations n’est offerte. Le coût pour l’intégration client est quand même non négligeable et n’est pas sans défauts.

Et pour cause, l’application en question est une app grand public, avec des dizaines de milliers d’utilisateurs réguliers. Et mTLS n’est pas réellement adapté pour un environnement “non contrôlé”, typiquement un grand nombre de smartphones. Plus le parc est grand, plus le risque de faille côté client est grand. Or, si la clé privée côté client ou le certificat Serveur venaient  à être compromis (et donc invalidés), c’est théoriquement toute l’application qui viendrait à être inutilisable. L’utilisation de Firebase pour rendre dynamique la récupération de ces informations permet de pallier ce souci, mais ce n’est pas infaillible. Si une personne malveillante est capable de récupérer le keystore de signature et de signer sa propre application en utilisant celui-ci, rien ne l’empêche de se faire passer pour un autre et d'avoir accès au serveur.

De manière générale, mTLS est plus adapté pour identifier un nombre fini de clients. On peut alors penser sur mobile que mTLS est fait pour identifier un smartphone (sur lequel l’app serait installée) et non une application spécifique. La gestion des certificats se ferait alors sur les Keystores des téléphones eux-mêmes ce qui permettrait de décharger la responsabilité de l’application de les récupérer à distance, en venant juste piocher dans ceux du téléphone.

La question qu’il faut se poser, c’est quelles sont les alternatives ou les compléments à mTLS ? Hélas, il n’y a pas de réponse miracle. Tout dépend de l’environnement de travail et des technologies à disposition. Et souvent, c’est une combinaison de différents outils qui permet d’avoir une sécurité “optimale”. Sans rentrer dans les détails, on peut alors mentionner le Certificate Pinning, le système App Check poussé par Google sur Android, ou encore des solutions plus généralistes telles que Approov.io qui peuvent très bien être implémentées sur mobile en fonction du besoin. D’ailleurs, il semblerait que Google travaille également à un détecteur de bots dans sa suite d’outils Play Services qui pourrait s’avérer fort utile si la fonctionnalité venait à se concrétiser.