Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 55ee16f1 authored by Marvin W.'s avatar Marvin W. 🐿️ Committed by Jonathan Klee
Browse files

Fido: Allow facetId / RP ID / AppId mismatch when delegated

parent f1daeaa2
Loading
Loading
Loading
Loading
Loading
+0 −2
Original line number Diff line number Diff line
@@ -23,8 +23,6 @@ import androidx.navigation.NavController
import androidx.navigation.navOptions
import androidx.navigation.ui.R

fun ByteArray.toHexString() : String = joinToString("") { "%02x".format(it) }

fun PackageManager.getApplicationInfoIfExists(packageName: String?, flags: Int = 0): ApplicationInfo? = packageName?.let {
    try {
        getApplicationInfo(it, flags)
+1 −1
Original line number Diff line number Diff line
@@ -5,7 +5,6 @@

package org.microg.gms.utils

import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.NameNotFoundException
import android.content.pm.Signature
@@ -25,6 +24,7 @@ fun PackageManager.getApplicationLabel(packageName: String): CharSequence = try
}

fun ByteArray.toBase64(vararg flags: Int): String = Base64.encodeToString(this, flags.fold(0) { a, b -> a or b })
fun ByteArray.toHexString(separator: String = "") : String = joinToString(separator) { "%02x".format(it) }

fun PackageManager.getFirstSignatureDigest(packageName: String, md: String): ByteArray? =
    getSignatures(packageName).firstOrNull()?.digest(md)
+2 −1
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ import org.json.JSONException
import org.json.JSONObject
import org.microg.gms.firebase.auth.getStringOrNull
import org.microg.gms.safetynet.SafetyNetSummary
import org.microg.gms.utils.toHexString


class SafetyNetRecentAttestationPreferencesFragment : PreferenceFragmentCompat() {
+1 −0
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ dependencies {
    implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
    implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion"

    implementation "com.android.volley:volley:$volleyVersion"
    implementation 'com.upokecenter:cbor:4.5.2'
    implementation 'com.google.guava:guava:31.1-android'
}
+139 −22
Original line number Diff line number Diff line
@@ -8,17 +8,19 @@ package org.microg.gms.fido.core
import android.content.Context
import android.net.Uri
import android.util.Base64
import com.android.volley.toolbox.JsonArrayRequest
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import com.google.android.gms.fido.fido2.api.common.*
import com.google.android.gms.fido.fido2.api.common.ErrorCode.*
import com.google.common.net.InternetDomainName
import com.upokecenter.cbor.CBORObject
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.CompletableDeferred
import org.json.JSONArray
import org.json.JSONObject
import org.microg.gms.fido.core.RequestOptionsType.REGISTER
import org.microg.gms.fido.core.RequestOptionsType.SIGN
import org.microg.gms.utils.getApplicationLabel
import org.microg.gms.utils.getFirstSignatureDigest
import org.microg.gms.utils.toBase64
import org.microg.gms.utils.*
import java.net.HttpURLConnection
import java.security.MessageDigest

class RequestHandlingException(val errorCode: ErrorCode, message: String? = null) : Exception(message)
@@ -42,7 +44,7 @@ val RequestOptions.signOptions: PublicKeyCredentialRequestOptions
val RequestOptions.type: RequestOptionsType
    get() = when (this) {
        is PublicKeyCredentialCreationOptions, is BrowserPublicKeyCredentialCreationOptions -> REGISTER
        is PublicKeyCredentialRequestOptions, is BrowserPublicKeyCredentialRequestOptions -> RequestOptionsType.SIGN
        is PublicKeyCredentialRequestOptions, is BrowserPublicKeyCredentialRequestOptions -> SIGN
        else -> throw RequestHandlingException(INVALID_STATE_ERR)
    }

@@ -67,7 +69,85 @@ val RequestOptions.rpId: String
val PublicKeyCredentialCreationOptions.skipAttestation: Boolean
    get() = attestationConveyancePreference in setOf(AttestationConveyancePreference.NONE, null)

fun RequestOptions.checkIsValid(context: Context) {
fun topDomainOf(string: String?) =
    string?.let { InternetDomainName.from(string).topDomainUnderRegistrySuffix().toString() }

fun <T> JSONArray.map(fn: JSONArray.(Int) -> T): List<T> = (0 until length()).map { fn(this, it) }

private suspend fun isFacetIdTrusted(context: Context, facetId: String, appId: String): Boolean {
    val trustedFacets = try {
        val deferred = CompletableDeferred<JSONObject>()
        HttpURLConnection.setFollowRedirects(false)
        Volley.newRequestQueue(context)
            .add(JsonObjectRequest(appId, { deferred.complete(it) }, { deferred.completeExceptionally(it) }))
        val obj = deferred.await()
        val arr = obj.getJSONArray("trustedFacets")
        if (arr.length() > 1) {
            // Unsupported
            emptyList()
        } else {
            arr.getJSONObject(0).getJSONArray("ids").map(JSONArray::getString)
        }
    } catch (e: Exception) {
        // Ignore and fail
        emptyList()
    }
    return trustedFacets.contains(facetId)
}

private const val ASSET_LINK_REL = "delegate_permission/common.get_login_creds"
private suspend fun isAssetLinked(context: Context, rpId: String, facetId: String, packageName: String?): Boolean {
    try {
        if (!facetId.startsWith("android:apk-key-hash-sha256:")) return false
        val fp = Base64.decode(facetId.substring(28), HASH_BASE64_FLAGS).toHexString(":")
        val deferred = CompletableDeferred<JSONArray>()
        HttpURLConnection.setFollowRedirects(true)
        val url = "https://$rpId/.well-known/assetlinks.json"
        Volley.newRequestQueue(context)
            .add(JsonArrayRequest(url, { deferred.complete(it) }, { deferred.completeExceptionally(it) }))
        val arr = deferred.await()
        for (obj in arr.map(JSONArray::getJSONObject)) {
            if (!obj.getJSONArray("relation").map(JSONArray::getString).contains(ASSET_LINK_REL)) continue
            val target = obj.getJSONObject("target")
            if (target.getString("namespace") != "android_app") continue
            if (packageName != null && target.getString("package_name") != packageName) continue
            for (fingerprint in target.getJSONArray("sha256_cert_fingerprints").map(JSONArray::getString)) {
                if (fingerprint.equals(fp, ignoreCase = true)) return true
            }
        }
        return false
    } catch (e: Exception) {
        return false
    }
}

// Note: This assumes the RP ID is allowed
private suspend fun isAppIdAllowed(context: Context, appId: String, facetId: String, rpId: String): Boolean {
    return try {
        when {
            topDomainOf(Uri.parse(appId).host) == topDomainOf(rpId) -> {
                // Valid: AppId TLD+1 matches RP ID
                true
            }
            topDomainOf(Uri.parse(appId).host) == "gstatic.com" && rpId == "google.com" -> {
                // Valid: Hardcoded support for Google putting their app id under gstatic.com.
                // This is gonna save us a ton of requests
                true
            }
            isFacetIdTrusted(context, facetId, appId) -> {
                // Valid: Allowed by TrustedFacets list
                true
            }
            else -> {
                false
            }
        }
    } catch (e: Exception) {
        false
    }
}

suspend fun RequestOptions.checkIsValid(context: Context, facetId: String, packageName: String?) {
    if (type == REGISTER) {
        if (registerOptions.authenticatorSelection.requireResidentKey == true) {
            throw RequestHandlingException(
@@ -81,25 +161,46 @@ fun RequestOptions.checkIsValid(context: Context) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "Request doesn't have a valid list of allowed credentials.")
        }
    }
    if (authenticationExtensions?.fidoAppIdExtension?.appId != null) {
        val appId = authenticationExtensions.fidoAppIdExtension.appId
    if (facetId.startsWith("https://")) {
        if (topDomainOf(Uri.parse(facetId).host) != topDomainOf(rpId)) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "RP ID $rpId not allowed from facet $facetId")
        }
        // FIXME: Standard suggests doing additional checks, but this is already sensible enough
    } else if (facetId.startsWith("android:apk-key-hash:") && packageName != null) {
        val sha256FacetId = getAltFacetId(context, packageName, facetId)
        if (!isAssetLinked(context, rpId, sha256FacetId, packageName)) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "RP ID $rpId not allowed from facet $sha256FacetId")
        }
    } else if (facetId.startsWith("android:apk-key-hash-sha256:")) {
        if (!isAssetLinked(context, rpId, facetId, packageName)) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "RP ID $rpId not allowed from facet $facetId")
        }
    } else {
        throw RequestHandlingException(NOT_SUPPORTED_ERR, "Facet $facetId not supported")
    }
    val appId = authenticationExtensions?.fidoAppIdExtension?.appId
    if (appId != null) {
        if (!appId.startsWith("https://")) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "FIDO AppId must start with https://")
            throw RequestHandlingException(NOT_ALLOWED_ERR, "AppId $appId must start with https://")
        }
        val uri = Uri.parse(appId)
        if (uri.host.isNullOrEmpty()) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "FIDO AppId must have a valid hostname")
        if (Uri.parse(appId).host.isNullOrEmpty()) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "AppId $appId must have a valid hostname")
        }
        if (InternetDomainName.from(uri.host).topDomainUnderRegistrySuffix() != InternetDomainName.from(rpId).topDomainUnderRegistrySuffix()) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "FIDO AppId must be same TLD+1")
        val altFacetId = packageName?.let { getAltFacetId(context, it, facetId) }
        if (!isAppIdAllowed(context, appId, facetId, rpId) &&
            (altFacetId == null || !isAppIdAllowed(context, appId, altFacetId, rpId))
        ) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "AppId $appId not allowed from facet $facetId/$altFacetId")
        }
    }
}

private const val HASH_BASE64_FLAGS = Base64.NO_PADDING + Base64.NO_WRAP + Base64.URL_SAFE

fun RequestOptions.getWebAuthnClientData(callingPackage: String, origin: String): ByteArray {
    val obj = JSONObject()
        .put("type", webAuthnType)
        .put("challenge", challenge.toBase64(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE))
        .put("challenge", challenge.toBase64(HASH_BASE64_FLAGS))
        .put("androidPackageName", callingPackage)
        .put("tokenBinding", tokenBinding?.toJsonObject())
        .put("origin", origin)
@@ -111,20 +212,36 @@ fun getApplicationName(context: Context, options: RequestOptions, callingPackage
    else -> context.packageManager.getApplicationLabel(callingPackage).toString()
}

fun getApkHashOrigin(context: Context, packageName: String): String {
    val digest = context.packageManager.getFirstSignatureDigest(packageName, "SHA-256")
fun getApkKeyHashFacetId(context: Context, packageName: String): String {
    val digest = context.packageManager.getFirstSignatureDigest(packageName, "SHA1")
        ?: throw RequestHandlingException(NOT_ALLOWED_ERR, "Unknown package $packageName")
    return "android:apk-key-hash:${digest.toBase64(HASH_BASE64_FLAGS)}"
}

fun getAltFacetId(context: Context, packageName: String, facetId: String): String {
    val firstSignature = context.packageManager.getSignatures(packageName).firstOrNull()
        ?: throw RequestHandlingException(NOT_ALLOWED_ERR, "Unknown package $packageName")
    return "android:apk-key-hash:${digest.toBase64(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE)}"
    return when (facetId) {
        "android:apk-key-hash:${firstSignature.digest("SHA1").toBase64(HASH_BASE64_FLAGS)}" -> {
            "android:apk-key-hash-sha256:${firstSignature.digest("SHA-256").toBase64(HASH_BASE64_FLAGS)}"
        }
        "android:apk-key-hash-sha256:${firstSignature.digest("SHA-256").toBase64(HASH_BASE64_FLAGS)}" -> {
            "android:apk-key-hash:${firstSignature.digest("SHA1").toBase64(HASH_BASE64_FLAGS)}"
        }
        else -> {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "Package $packageName does not match facet $facetId")
        }
    }
}

fun getOrigin(context: Context, options: RequestOptions, callingPackage: String): String = when {
fun getFacetId(context: Context, options: RequestOptions, callingPackage: String): String = when {
    options is BrowserRequestOptions -> {
        if (options.origin.scheme == null || options.origin.authority == null) {
            throw RequestHandlingException(NOT_ALLOWED_ERR, "Bad url ${options.origin}")
        }
        "${options.origin.scheme}://${options.origin.authority}"
    }
    else -> getApkHashOrigin(context, callingPackage)
    else -> getApkKeyHashFacetId(context, callingPackage)
}

fun ByteArray.digest(md: String): ByteArray = MessageDigest.getInstance(md).digest(this)
@@ -137,7 +254,7 @@ fun getClientDataAndHash(
    val clientData: ByteArray?
    var clientDataHash = (options as? BrowserPublicKeyCredentialCreationOptions)?.clientDataHash
    if (clientDataHash == null) {
        clientData = options.getWebAuthnClientData(callingPackage, getOrigin(context, options, callingPackage))
        clientData = options.getWebAuthnClientData(callingPackage, getFacetId(context, options, callingPackage))
        clientDataHash = clientData.digest("SHA-256")
    } else {
        clientData = "<invalid>".toByteArray()
Loading