Secrets in Android Part 2

Artsem Kurantsou
6 min readMay 6, 2024

--

Secrets in Android illustration (AI generated)

Many of the Android apps require some secrets like API keys to work. In most cases these keys are for Google services and they can be protected based on the app signing key (this can be configured in the Google Cloud Console) so it’s not a problem if they are leaked. However, sometimes we have to add keys or other secrets that are not protected and need to be hidden. In these articles I’m going to show ways to do it.

There are 2 approaches for the client secrets:

  • Bundle secrets in the app binary
  • Fetch them from remote storage (server)

In this part I will demonstrate the second approach.

The main advantage of this approach is that secrets are more secure as not included in the bundle, so the attack surface is transferred to the remote service, making the task more difficult for the attacker.

Another advantage is that secrets can be rotated (if compromised or expired) without the need to update client app (release new version).

Remote configuration

Most of Android apps use some of the Firebase services like Cloud Messaging, Realtime database, Crashlytics, etc.

One of the services that are provided by the Firebase is Remote Configuration. It has rich functionality for A/B testing as values for the configuration can be different per Country, App version and many more.

Let’s see how can we use it to retrieve secrets.

Create Firebase project and create remote configuration in the console

In my case the config looks like this:

Remote configuration

Integrate Firebase services into the app

As the first step you’ll need to download google-services.json file that specifies all the information about Firebase project.

Then you’ll need to apply play-services gradle plugin. This plugin reads content of the google-services.json file and generates string resources for the application which looks like the following:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="default_web_client_id" translatable="false">442808137136-fuaph2labpcoo6iosp9uak8f5mqjk2bi.apps.googleusercontent.com</string>
<string name="gcm_defaultSenderId" translatable="false">442808137136</string>
<string name="google_api_key" translatable="false">AIzaSyA67njI-zrCiwrFBsOM5uFBfAvpdp_bq0c</string>
<string name="google_app_id" translatable="false">1:442808137136:android:a95fdc8e6c74f4e58c1275</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyA67njI-zrCiwrFBsOM5uFBfAvpdp_bq0c</string>
<string name="google_storage_bucket" translatable="false">integrity-api-poc-390116.appspot.com</string>
<string name="project_id" translatable="false">integrity-api-poc-390116</string>
</resources>

Use FirebaseRemoteConfig to get the secrets

Here is an example of the data store implementation:

internal class RemoteConfigSecretsDataSource @Inject constructor(
private val remoteConfig: FirebaseRemoteConfig,
private val remoteConfigDtoMapper: RemoteConfigDtoMapper,
) : RemoteSecretsDataSource {
private val configurationTask: Task<Void>

init {
val configuration = FirebaseRemoteConfigSettings.Builder()
.setFetchTimeoutInSeconds(FETCH_INTERVAL.inWholeSeconds)
.build()
configurationTask = remoteConfig.setConfigSettingsAsync(configuration)
}

override suspend fun getSecrets(): Secrets = withContext(Dispatchers.IO) {
ensureConfigFetched()
val dtoString = remoteConfig.getString(SECRETS_KEY)
return@withContext remoteConfigDtoMapper(dtoString)
}

private suspend fun ensureConfigFetched() {
configurationTask.await()
// Checks if remote configuration is fresh enough
if (remoteConfig.info.lastFetchStatus == FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET
|| (System.currentTimeMillis() - remoteConfig.info.fetchTimeMillis).milliseconds >= FETCH_INTERVAL
) {
// Fetches and activates latest values
remoteConfig.fetchAndActivate().await()
} else {
// Activates current cached values
remoteConfig.activate()
}
}

private companion object {
const val SECRETS_KEY = "SECRETS"
val FETCH_INTERVAL: Duration = 1.days
}
}
internal class RemoteConfigDtoMapper(private val json: Json) {
@Inject
constructor() : this(Json { ignoreUnknownKeys = true })

operator fun invoke(dtoString: String): Secrets {
val dto = json.decodeFromString<RemoteConfigDTO>(dtoString)
return Secrets(
serverApiKey = dto.apiKey,
serverApiPassword = dto.apiPassword,
)
}

@Serializable
private data class RemoteConfigDTO(
@SerialName("API_KEY")
val apiKey: String,
@SerialName("API_PASSWORD")
val apiPassword: String,
)
}

Demo

Firebase Remote Config Demo

Verdict

This approach is very easy to use and integrate but it has huge disadvantage — it doesn’t have any protection. So any app that knows Firebase project identifiers (that can be easily extracted from the app bundle) can fetch the values from Remote Configuration.

Play integrity API / Firebase App Check

The main problem for the backend-based approaches is that we need to identify which calls are made from legitimate clients and which are not. If we simply add some API call to the backend from the app to fetch the secrets it’s still can be reverse engineered fairly easily.

The way client can validate that it “talks” to the legitimate backend already exists and called “Certificate Pinning”. This mechanism requires app to have set of certificates pins of the backend SSL certificate. These pins then validated during SSL handshake and if no known pins found in the certificates chain, handshake fails and API call is not performed. This mechanism is used to protect the client from the Man-In-The-Middle attacks.

However, Certificate pinning can’t be used from the backend side to validate the call “source”. We can’t include something in the call itself (some identifier, API KEY or something else) neither as we are getting to the same problem that we are trying to solve. So, the best approach would be to validate that the call is made from the app that is signed by us (with known key). Play Integrity API provides such functionality.

This API uses Google Play services to validate the the application wasn’t modified and is signed with correct key (comparing with the app distributed by the Google Play).

Flow

Integrity API flow (From official docs— https://developer.android.com/google/play/integrity/standard)
  1. The app requests integrity token from Google Play Integrity API
  2. The app makes Backend API request with the integrity token
  3. Backend validates integrity token and if it’s valid responds with secrets

The flow itself looks pretty simple, however it requires to handle attestation result correctly which can be tricky. Firebase provides even simpler flow with Firebase App Check that uses Google Play Integrity API under the hood.

To use Firebase App Check it’s need to be enabled in the Firebase console.

Use Firebase App Check to get the secrets

Firebase App Check initialization:

val firebaseAppCheck = FirebaseAppCheck.getInstance()
firebaseAppCheck.installAppCheckProviderFactory(
PlayIntegrityAppCheckProviderFactory.getInstance()
)

Data source implementation:

internal class BackendSecretsDataSource @Inject constructor(
private val client: HttpClient,
private val appCheck: FirebaseAppCheck,
private val mapper: SecretsDtoMapper,
) : RemoteSecretsDataSource {
override suspend fun getSecrets(): Secrets = withContext(Dispatchers.IO) {
// Fetches AppCheck token
val token = appCheck.limitedUseAppCheckToken
.await()
.token

// Sends request to the Backend with a token to get secrets
val dto = client.get("/secrets") {
headers {
append("X-Firebase-AppCheck", token)
}
}.body<SecretsDto>()

return@withContext mapper.map(dto)
}
}

Server implementation

Validation on the server side can look the following way:

@Serializable
data class SecretsResponse(
val apiKey: String,
val apiPassword: String,
)


private val SERVER_SECRETS = SecretsResponse(
apiKey = "VERY_SECRET_API_KEY",
apiPassword = "VERY_SECRET_API_PASSWORD",
)

private const val FIREBASE_PROJECT_NUMBER = "442808137136"

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
configureRouting()
configureSerialization()
}

fun Application.configureRouting() {
routing {
secretsRouting()
}
}

fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
}

fun Route.secretsRouting() {
route("/secrets") {
get {
val token = call.request.headers["X-Firebase-AppCheck"] ?: run {
call.respond(HttpStatusCode.Unauthorized, "No required header")
return@get
}

val jwt = try {
val keys =
JSONWebKeySetHelper.retrieveKeysFromJWKS("https://firebaseappcheck.googleapis.com/v1/jwks")

val verifiers = keys.map {
RSAVerifier.newVerifier(JSONWebKey.parse(it))
}

verifiers.firstNotNullOf { verifier ->
runCatching {
JWT.getDecoder().decode(token, verifier)
}.getOrNull()
}
} catch (e: Exception) {
println("Error decoding token: ${e.message}")
call.respond(HttpStatusCode.Unauthorized, "Error during token decoding")
return@get
}
if (
jwt.header.algorithm != Algorithm.RS256 ||
jwt.header.type != "JWT" ||
jwt.issuer != "https://firebaseappcheck.googleapis.com/$FIREBASE_PROJECT_NUMBER" ||
jwt.isExpired ||
(jwt.audience as List<*>).none { it != "projects/$FIREBASE_PROJECT_NUMBER" }
) {
call.respond(HttpStatusCode.Unauthorized, "Error during token validation")
return@get
}

call.respond(HttpStatusCode.OK, SERVER_SECRETS)
}
}
}

Demo

Success case (when app is signed with expected key)

Success Backend Demo

Error case (when app is signed with unexpected key)

Error Backend Demo

Verdict

This approach is significantly improves security of the app and makes it much harder for the attacker to retrieve the actual secrets. However, it’s not absolute defense and has some disadvantages:

  • Google Play Services are required to get an integrity token
  • Play Integrity API has limit of 10k attestations per month for free

--

--