Sicherer Umgang mit API-Keys in Android Apps​

Im Zuge unserer Penetrationstests und Sicherheitsüberprüfungen von mobilen Anwendungen, ist der sichere Umgang mit API-Keys in Android und iOS regelmäßig ein Befund. Hierbei fällt insbesondere auf, dass Softwareentwickler ihre API-Keys „hard-coded“ in die AndroidManifest.xml oder direkt in den Quellcode der App speichern. Für Angreifer ist es dadurch mit wenig Aufwand verbunden, an genau diese API-Keys zu gelangen. Welchen Schaden ein Angreifer damit anrichten kann lässt sich zwar durch Einschränkungen (dazu weiter unten mehr) eingrenzen, allerdings nicht vollständig vermeiden. Nur ein sicherer Umgang mit API-Keys kann dieses Restrisiko vermeiden. Da in der Softwareentwicklung alles außerhalb der eigenen Kontrolle – also insbesondere der Client – aus sicherheitstechnischer Sicht „verloren“ ist, sollten die API-Keys möglichst gut geschützt werden, um den Aufwand für Angreifer so groß wie möglich zu halten. 

 

In den folgenden Abschnitten zeigen wir ein Beispiel aus der Praxis, wie sich API-Keys beispielsweise für Google-Dienste sicher speichern lassen – und welche weitläufige Empfehlung eher keinen Nutzen hat. 

Sicherer Umgang mit API-Keys in Android
Unsichere Speicherung von API-Keys in Android - Hier: AndroidManifest.xml als Sicherheitsrisiko

Android-Keystore-System - Der richtige Ansatz

Die bisher beste Möglichkeit, um API-Keys und co. sicher auf dem Gerät zu speichern, bietet das Android-Keystore-System. Anders als der Name „Keystore“ vielleicht vermuten lässt, können hier allerdings nicht direkt die API-Keys hineingespeichert werden.

The Android Keystore system lets you store cryptographic keys in a container to make them more difficult to extract from the device. Once keys are in the keystore, you can use them for cryptographic operations, with the key material remaining non-exportable. Also, the keystore system lets you restrict when and how keys can be used, such as requiring user authentication for key use or restricting keys to use only in certain cryptographic modes. See the Security Features section for more information.“ 1

– Android Keystore system / Android Developers Guide

Das Android-Keystore-System stellt also nur einen kryptographischen Schlüssel bereit, mit dessen Hilfe ein gewisser Kontext, zum Beispiel Texte oder Dateiinhalte, ver- und entschlüsselt werden können. Aus sicherheitstechnischer Sicht ist das eine große Hürde für potenzielle Angreifer, allerdings wird der Kontext immer noch auf dem Gerät gespeichert und ist somit möglichen Angriffen ausgeliefert. 

Umsetzungsbeispiel des Android-Keystore-Systems in einer Kotlin-App

Da es heutzutage viele Möglichkeiten gibt eine App für Android zu schreiben, haben wir uns für Kotlin entschieden. Zwar ändern sich die jeweiligen Aufrufe von Programmiersprache zu Programmiersprache, allerdings bleibt das Prinzip bestehen und der Code muss ggf. entsprechend angepasst werden. Die nachfolgenden Code-Beispiele sind auch in unserem GitHub Repository zu finden.

Fangen wir mit den beiden Datenklassen DecryptionData und EncryptionData an. Wie der Name es vermuten lässt, sind diese zum Speichern der Daten während der Laufzeit verantwortlich. Sie ermöglichen eine einfachere Verwaltung der ver- und entschlüsselten Daten als über einzelne Parameter oder Variablen die zwischen den Klassen ausgetauscht werden müssen.

data class DecryptionData(
    val alias: String,
    val data: String
)
data class EncryptionData(
    val alias: String,
    val data: String,
    val iv: String
)

Nachfolgend der Code für die KeystoreManager Klasse. Diese stellt die benötigten Methoden zum Ver- und Entschlüsseln der Daten bereit. Die Daten werden im CBC-Modus mittels AES-verschlüsselt. Da bei der Verschlüsselung ein Initialisierungsvektor erforderlich und genutzt wird, muss dieser ebenfalls wieder zur Entschlüsselung bereitgestellt werden. Außerdem ist es bei der Verschlüsselung zusätzlich möglich anzugeben, ob eine User Authentifizierung erforderlich ist.

class KeystoreManager {

    [...]
    
    fun encrypt(decryptionData: DecryptionData): EncryptionData {
    
            val keyGenerator: KeyGenerator = KeyGenerator.getInstance(ALGORITHM, PROVIDER)
             
            val keyGenParameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder(
                    decryptionData.alias, PURPOSE
            )
            .setBlockModes(BLOCK_MODE)
            .setEncryptionPaddings(PADDING)
            .setUserAuthenticationRequired(false)
            .setRandomizedEncryptionRequired(true)
            .build()
             
            [...]
             
            val encryptedBytes = cipher.doFinal(decryptionData.data.toByteArray())
            val data = ENCODER.encodeToString(encryptedBytes)
            val iv = ENCODER.encodeToString(cipher.iv)
             
            return EncryptionData(
                    data = data.replace(NEW_LINE, EMPTY),
                    iv = iv.replace(NEW_LINE, EMPTY),
                    alias = decryptionData.alias
            )
    }
    
    fun decrypt(encryptionData: EncryptionData): DecryptionData {
     
            val iv = DECODER.decode(encryptionData.iv)
            val data = DECODER.decode(encryptionData.data)
             
            [...]
             
            cipher.init(Cipher.DECRYPT_MODE, secretKeyEntry.secretKey, IvParameterSpec(iv))
             
            val decryptedBytes = String(cipher.doFinal(data))
             
            return DecryptionData(
                    alias = encryptionData.alias,
                    data = decryptedBytes
            )
    }

}

Die KeyHandler Klasse, wie sie im nachfolgenden Beispiel zu sehen ist, ist dafür gedacht, um ggf. mehrere API Keys von unterschiedlichen Servern oder Diensten zentral in einer Klasse verwalten und abrufen zu können. Die Anfrage, um den Schlüssel zu erhalten, wie sie in diesem Beispiel zu sehen ist, sollte allerdings nicht so wie in dem Beispiel in der Praxis umgesetzt werden, da jeder ohne eine Authentifizierung den API-Key vom entsprechenden Server abfragen kann. 

Um die API-Key-Bereitstellung von der Serverseite aus sicherer zu gestalten, sollten folgende Faktoren berücksichtigt werden:

Keine Schlüsselausgabe:

  • an unautorisierte Requests
  • ohne gültige Signatur der App
  • mit uneingeschränkten Berechtigungen
  • ohne rate limiting
class KeyHandler {

    fun getSecretApiKey(): String {
        val data = KeystorePreference.get("secretApiKey")
        
        if(!data.isNullOrEmpty()) {
            return "$data"
        }
        
        /*
        * Retrieve api key from server
        */
        val url = "https://pentest.bi-sec.cloud/secret-api-key.php"
        val connection = URL(url).openConnection() as HttpURLConnection
        val apikey = connection.inputStream.bufferedReader().readText()
        
        // Save in shared preferences
        KeystorePreference.save(key="secretApiKey", apikey)
        
        return apikey
    }
}

Das KeystorePreference Objekt ist für das Speichern und Auslesen der Daten aus der Datei aus dem „Shared Preferences“-Order innerhalb des App-Verzeichnisses zuständig. Vor dem Speichern werden die Daten verschlüsselt und anschließend unter dem vorher definierten Alias als JSON-Objekt gespeichert. Beim Auslesen aus der Datei werden die Daten wieder entschlüsselt. Aufgrund des späteren Auslesens aus der Datei ist auch der Initialisierungsvektor (iv) wichtig. Ohne diesen kann der Text nicht mehr entschlüsselt werden. 

object KeystorePreference {
    private const val FILENAME = "__secret_data"
     
    [...]
     
    fun get(alias: String): String? {

        [...]
         
        val encryptionData = EncryptionData(
            iv = jsonObject.getString("iv"), 
            alias = alias, 
            data = jsonObject.getString("data"), 
        )
         
        return KeystoreManager().decrypt(encryptionData).data
    }
 
    fun save(key: String, value: String): EncryptionData {
 
        [...]
     
        val decryptionData = DecryptionData(
            alias = key, 
            data = value
        )
        val encrypted = KeystoreManager().encrypt(decryptionData)
     
        val json = JSONObject().apply {
            put("data", encrypted.data)
            put("iv", encrypted.iv)
        }
      
        sharedPreferences.edit().putString(key, json.toString()).apply()
     
        return encrypted
    }
}

In unserer Beispiel-App zeigen wir die Schlüssel nur zu Demozecken in der App an. In der Praxis sollten diese Schlüssel niemals angezeigt werden, sondern im Hintergrund mit Requests an die jeweiligen Server geschickt werden, um die erforderlichen Daten für unsere App zu erhalten. Zum Beispiel eine Karte von Google Maps.  

class MainActivity : AppCompatActivity() {

    [...]

    override fun onCreate(savedInstanceState: Bundle?) {

        [...]

        KeystorePreference.init(application)

        // Display api key from C++ file
        findViewById(R.id.txt_via_cpp).text = Keys.apiKey()

        // Extra thread due to the needed network connection
        Thread {
            // Get api key from shared prefs
            val string = KeyHandler().getSecretApiKey()

            runOnUiThread {
                findViewById(R.id.txt_via_keystore).text = string
            }
        }.start()
    }
}

So sieht der Inhalt der __secret_data.xml Datei aus, die im shared_prefs/ Ordner innerhalb des App-Verzeichnisses gespeichert wird:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="secretApiKey">
       {"data":"ctdlbKIqjBAgiRu1HGMqOheavt8knLaiNBn2PD3Pwm8","iv":"wNPCexyrS\/qKzEj4w5ZcHA"}
    </string>
</map>

Hierzu noch eine Anmerkung zum Thema „Security by obscurity“

Durch Änderung des Namens unter dem der Schlüssel in der Datei gespeichert wird und durch Umbenennung der Datei wird es schwieriger für den Angreifer eine Zuordnung zu finden. Je größer die Hürde für den Angreifer ist, desto besser ist es aus der sicherheitstechnischen Sicht. Allerdings leidet die Wartbarkeit und spätere Entwicklung darunter und hat aufgrund der bestehenden Verschlüsselung keinen relevanten Mehrwert. 

Speichern der API-Keys in C++-Dateien - Nice Try

Als weitere Möglichkeit der vermeintlich sicheren Speicherung von API-Keys wird geraten, diese in C++ Dateien, gradle.properties oder in die build.gradle Datei zu speichern. Grundidee ist es, es den Angreifern schwerer zu machen, da vor allem C++-Dateien aufwändiger zu dekompilieren sind als eine normale APK-Datei. Der Vorteil ist zwar, dass die Schlüssel nicht mehr direkt innerhalb des Quellcodes oder der AndroidManifest.xml liegen, allerdings sind sie so immer noch leicht zu finden, wenn man weiß, an welcher Stelle man suchen muss. 

Wie im nachfolgenden C++-Codebeispiel zu sehen ist, wird der API-Key weiterhin als Klartext in der Datei gespeichert:

#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring

JNICALL
Java_com_bisec_securekeystorage_util_Keys_apiKey(JNIEnv *env, jobject object) {
    std::string api_key = "##thisisasupersecretapikeynooneshouldbeabletoseeit##";

    return env->NewStringUTF(api_key.c_str());
}

Was im ersten Moment nach dem build-Prozess der APK vielleicht nach einer guten Idee für den sicheren Umgang mit API-Keys aussieht, entpuppt sich nach kurzer „Strings“-Suche dann doch als unsicher. Jeder Angreifer mit Zugriff auf die APK und dem nötigen Know-how kann die vorhandenen Schlüssel auslesen, denn durch das Dekompilieren der App erhält man Zugriff auf die Dateien, die den Schlüssel im Klartext beinhalten. Es fehlt zwar die Referenz zu welcher API Schnittstelle der Schlüssel gehört oder welche der Zahlen und Buchstaben Kombinationen der richtige Schlüssel für die gesuchte API-Schnittstelle ist, aber durch ein bisschen probieren oder durch Auswertung von spezifischen Pattern lassen sich viele Schlüssel schnell zuordnen. Daher raten wir von dieser Möglichkeit eher ab.

Ein Beispiel für diese Art der Speicherung ist auch in unserem GitHub Repository zu finden.

Im nachfolgenden Ausschnitt ist der API-Key zu sehen, den wir im vorherigen Beispiel als Klartext in die C++-Datei gespeichert haben. Wie klar zu erkennen ist, lässt sich der Schlüssel weiterhin im Klartext lesen und wurde nicht verschlüsselt.

PasswordInFile
Unsicherer Umgang mit API-Keys: Ablage in C++-Dateien

Fazit: Sicherer Umgang mit API-Keys in Android

Generell führt nach unserem aktuellen Wissensstand ohne Reverse-Proxy kein Weg daran vorbei die Schlüssel auf dem Gerät zu speichern. Um trotz verschlüsselter Speicherung den möglichen Schaden so gering wie möglich zu halten, sollten wie weiter oben bereits erwähnt, die API-Keys (sofern möglich) serverseitig auf die nötigsten Funktionalitäten beschränkt werden. Außerdem empfehlen wir ein rate limiting zu implementieren, da manche API-Schnittstellen mit Kosten pro Aufruf verbunden sind.

Hier noch ein theoretisches Beispiel für die Umsetzung der API-Aufrufe mit Hilfe eines Reverse Proxys:

ReverseProxy
API-Aufrufe via Reverse Proxy.

Der Client, in unserem Fall die App, sendet eine einfache Anfrage an den Proxy-Server, um an die benötigten Daten des API-Servers zu kommen. Der Reverse Proxy validiert die Gültigkeit und Vertraulichkeit der Anfrage des Clients und fügt bei erfolgreicher Validierung den API-Token zur Anfrage hinzu und leitet diese an den tatsächlichen API-Server weiter. Die Response wird anschließend vom API-Server, über den Reverse Proxy, an den Client übermittelt. Durch diese Art von Implementierung bleibt der API-Token vertraulich und muss nicht an auf dem Endgerät abgelegt werden – obgleich das Problem der Authentifizierung des Clients bleibt. Immerhin: In diesem Szenario haben Sie im Reverse Proxy die Kontrolle darüber, wie oft und wie viel die Ziel-API angefragt wird.