diff --git a/play-services-base-core-ui/src/main/kotlin/org/microg/gms/ui/Utils.kt b/play-services-base-core-ui/src/main/kotlin/org/microg/gms/ui/Utils.kt index 725ec5c848..5df78d3d76 100644 --- a/play-services-base-core-ui/src/main/kotlin/org/microg/gms/ui/Utils.kt +++ b/play-services-base-core-ui/src/main/kotlin/org/microg/gms/ui/Utils.kt @@ -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) diff --git a/play-services-base-core/src/main/kotlin/org/microg/gms/utils/PackageManagerUtils.kt b/play-services-base-core/src/main/kotlin/org/microg/gms/utils/PackageManagerUtils.kt index e4fbc7418a..dd488435f3 100644 --- a/play-services-base-core/src/main/kotlin/org/microg/gms/utils/PackageManagerUtils.kt +++ b/play-services-base-core/src/main/kotlin/org/microg/gms/utils/PackageManagerUtils.kt @@ -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) diff --git a/play-services-core/src/main/kotlin/org/microg/gms/ui/SafetyNetRecentAttestationPreferencesFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/ui/SafetyNetRecentAttestationPreferencesFragment.kt index 845601afd2..22e0aac382 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/ui/SafetyNetRecentAttestationPreferencesFragment.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/ui/SafetyNetRecentAttestationPreferencesFragment.kt @@ -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() { @@ -91,4 +92,4 @@ class SafetyNetRecentAttestationPreferencesFragment : PreferenceFragmentCompat() } -} \ No newline at end of file +} diff --git a/play-services-fido-core/build.gradle b/play-services-fido-core/build.gradle index 25bd158d00..76c6199d8e 100644 --- a/play-services-fido-core/build.gradle +++ b/play-services-fido-core/build.gradle @@ -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' } diff --git a/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt b/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt index 4288adc76e..8edbd3b596 100644 --- a/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt +++ b/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt @@ -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 JSONArray.map(fn: JSONArray.(Int) -> T): List = (0 until length()).map { fn(this, it) } + +private suspend fun isFacetIdTrusted(context: Context, facetId: String, appId: String): Boolean { + val trustedFacets = try { + val deferred = CompletableDeferred() + 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() + 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(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE)}" + 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 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 = "".toByteArray() diff --git a/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt b/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt index d69bc6ac89..73271cc628 100644 --- a/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt +++ b/play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt @@ -12,8 +12,8 @@ import android.os.Build import android.os.Bundle import android.util.Base64 import android.util.Log +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.OnNewIntentProvider import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.NavHostFragment @@ -93,30 +93,55 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { Log.d(TAG, "onCreate caller=$callerPackage options=$options") - options.checkIsValid(this) - val origin = getOrigin(this, options, callerPackage) + val requiresPrivilege = + options is BrowserRequestOptions && !database.isPrivileged(callerPackage, callerSignature) + + // Check if we can directly open screen lock handling + if (!requiresPrivilege) { + val instantTransport = transportHandlers.firstOrNull { it.isSupported && it.shouldBeUsedInstantly(options) } + if (instantTransport != null && instantTransport.transport in INSTANT_SUPPORTED_TRANSPORTS) { + window.setBackgroundDrawable(ColorDrawable(0)) + window.statusBarColor = Color.TRANSPARENT + setTheme(R.style.Theme_Fido_Translucent) + } + } + + setTheme(R.style.Theme_AppCompat_DayNight_NoActionBar) + setContentView(R.layout.fido_authenticator_activity) + + lifecycleScope.launchWhenCreated { + handleRequest(options) + } + } catch (e: RequestHandlingException) { + finishWithError(e.errorCode, e.message ?: e.errorCode.name) + } catch (e: Exception) { + Log.w(TAG, e) + finishWithError(UNKNOWN_ERR, e.message ?: e.javaClass.simpleName) + } + } + + @RequiresApi(24) + suspend fun handleRequest(options: RequestOptions) { + try { + val facetId = getFacetId(this, options, callerPackage) + options.checkIsValid(this, facetId, callerPackage) val appName = getApplicationName(this, options, callerPackage) val callerName = packageManager.getApplicationLabel(callerPackage).toString() val requiresPrivilege = options is BrowserRequestOptions && !database.isPrivileged(callerPackage, callerSignature) - Log.d(TAG, "origin=$origin, appName=$appName") + Log.d(TAG, "facetId=$facetId, appName=$appName") // Check if we can directly open screen lock handling if (!requiresPrivilege) { val instantTransport = transportHandlers.firstOrNull { it.isSupported && it.shouldBeUsedInstantly(options) } if (instantTransport != null && instantTransport.transport in INSTANT_SUPPORTED_TRANSPORTS) { - window.setBackgroundDrawable(ColorDrawable(0)) - window.statusBarColor = Color.TRANSPARENT - setTheme(R.style.Theme_Fido_Translucent) startTransportHandling(instantTransport.transport) return } } - setTheme(R.style.Theme_AppCompat_DayNight_NoActionBar) - setContentView(R.layout.fido_authenticator_activity) val arguments = AuthenticatorActivityFragmentData().apply { this.appName = appName this.isFirst = true