Skip to content

Commit

Permalink
Fido: Allow facetId / RP ID / AppId mismatch when delegated
Browse files Browse the repository at this point in the history
  • Loading branch information
mar-v-in committed Oct 6, 2022
1 parent 4cd7c92 commit 6d0702f
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -91,4 +92,4 @@ class SafetyNetRecentAttestationPreferencesFragment : PreferenceFragmentCompat()

}

}
}
1 change: 1 addition & 0 deletions play-services-fido-core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 6d0702f

Please sign in to comment.