Skip to content

Commit 6d0702f

Browse files
committed
Fido: Allow facetId / RP ID / AppId mismatch when delegated
1 parent 4cd7c92 commit 6d0702f

6 files changed

Lines changed: 177 additions & 35 deletions

File tree

play-services-base-core-ui/src/main/kotlin/org/microg/gms/ui/Utils.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ import androidx.navigation.NavController
2323
import androidx.navigation.navOptions
2424
import androidx.navigation.ui.R
2525

26-
fun ByteArray.toHexString() : String = joinToString("") { "%02x".format(it) }
27-
2826
fun PackageManager.getApplicationInfoIfExists(packageName: String?, flags: Int = 0): ApplicationInfo? = packageName?.let {
2927
try {
3028
getApplicationInfo(it, flags)

play-services-base-core/src/main/kotlin/org/microg/gms/utils/PackageManagerUtils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
package org.microg.gms.utils
77

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

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

2929
fun PackageManager.getFirstSignatureDigest(packageName: String, md: String): ByteArray? =
3030
getSignatures(packageName).firstOrNull()?.digest(md)

play-services-core/src/main/kotlin/org/microg/gms/ui/SafetyNetRecentAttestationPreferencesFragment.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import org.json.JSONException
1414
import org.json.JSONObject
1515
import org.microg.gms.firebase.auth.getStringOrNull
1616
import org.microg.gms.safetynet.SafetyNetSummary
17+
import org.microg.gms.utils.toHexString
1718

1819

1920
class SafetyNetRecentAttestationPreferencesFragment : PreferenceFragmentCompat() {
@@ -91,4 +92,4 @@ class SafetyNetRecentAttestationPreferencesFragment : PreferenceFragmentCompat()
9192

9293
}
9394

94-
}
95+
}

play-services-fido-core/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies {
3030
implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
3131
implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion"
3232

33+
implementation "com.android.volley:volley:$volleyVersion"
3334
implementation 'com.upokecenter:cbor:4.5.2'
3435
implementation 'com.google.guava:guava:31.1-android'
3536
}

play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt

Lines changed: 139 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@ package org.microg.gms.fido.core
88
import android.content.Context
99
import android.net.Uri
1010
import android.util.Base64
11+
import com.android.volley.toolbox.JsonArrayRequest
12+
import com.android.volley.toolbox.JsonObjectRequest
13+
import com.android.volley.toolbox.Volley
1114
import com.google.android.gms.fido.fido2.api.common.*
1215
import com.google.android.gms.fido.fido2.api.common.ErrorCode.*
1316
import com.google.common.net.InternetDomainName
14-
import com.upokecenter.cbor.CBORObject
15-
import kotlinx.coroutines.runBlocking
17+
import kotlinx.coroutines.CompletableDeferred
18+
import org.json.JSONArray
1619
import org.json.JSONObject
1720
import org.microg.gms.fido.core.RequestOptionsType.REGISTER
1821
import org.microg.gms.fido.core.RequestOptionsType.SIGN
19-
import org.microg.gms.utils.getApplicationLabel
20-
import org.microg.gms.utils.getFirstSignatureDigest
21-
import org.microg.gms.utils.toBase64
22+
import org.microg.gms.utils.*
23+
import java.net.HttpURLConnection
2224
import java.security.MessageDigest
2325

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

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

70-
fun RequestOptions.checkIsValid(context: Context) {
72+
fun topDomainOf(string: String?) =
73+
string?.let { InternetDomainName.from(string).topDomainUnderRegistrySuffix().toString() }
74+
75+
fun <T> JSONArray.map(fn: JSONArray.(Int) -> T): List<T> = (0 until length()).map { fn(this, it) }
76+
77+
private suspend fun isFacetIdTrusted(context: Context, facetId: String, appId: String): Boolean {
78+
val trustedFacets = try {
79+
val deferred = CompletableDeferred<JSONObject>()
80+
HttpURLConnection.setFollowRedirects(false)
81+
Volley.newRequestQueue(context)
82+
.add(JsonObjectRequest(appId, { deferred.complete(it) }, { deferred.completeExceptionally(it) }))
83+
val obj = deferred.await()
84+
val arr = obj.getJSONArray("trustedFacets")
85+
if (arr.length() > 1) {
86+
// Unsupported
87+
emptyList()
88+
} else {
89+
arr.getJSONObject(0).getJSONArray("ids").map(JSONArray::getString)
90+
}
91+
} catch (e: Exception) {
92+
// Ignore and fail
93+
emptyList()
94+
}
95+
return trustedFacets.contains(facetId)
96+
}
97+
98+
private const val ASSET_LINK_REL = "delegate_permission/common.get_login_creds"
99+
private suspend fun isAssetLinked(context: Context, rpId: String, facetId: String, packageName: String?): Boolean {
100+
try {
101+
if (!facetId.startsWith("android:apk-key-hash-sha256:")) return false
102+
val fp = Base64.decode(facetId.substring(28), HASH_BASE64_FLAGS).toHexString(":")
103+
val deferred = CompletableDeferred<JSONArray>()
104+
HttpURLConnection.setFollowRedirects(true)
105+
val url = "https://$rpId/.well-known/assetlinks.json"
106+
Volley.newRequestQueue(context)
107+
.add(JsonArrayRequest(url, { deferred.complete(it) }, { deferred.completeExceptionally(it) }))
108+
val arr = deferred.await()
109+
for (obj in arr.map(JSONArray::getJSONObject)) {
110+
if (!obj.getJSONArray("relation").map(JSONArray::getString).contains(ASSET_LINK_REL)) continue
111+
val target = obj.getJSONObject("target")
112+
if (target.getString("namespace") != "android_app") continue
113+
if (packageName != null && target.getString("package_name") != packageName) continue
114+
for (fingerprint in target.getJSONArray("sha256_cert_fingerprints").map(JSONArray::getString)) {
115+
if (fingerprint.equals(fp, ignoreCase = true)) return true
116+
}
117+
}
118+
return false
119+
} catch (e: Exception) {
120+
return false
121+
}
122+
}
123+
124+
// Note: This assumes the RP ID is allowed
125+
private suspend fun isAppIdAllowed(context: Context, appId: String, facetId: String, rpId: String): Boolean {
126+
return try {
127+
when {
128+
topDomainOf(Uri.parse(appId).host) == topDomainOf(rpId) -> {
129+
// Valid: AppId TLD+1 matches RP ID
130+
true
131+
}
132+
topDomainOf(Uri.parse(appId).host) == "gstatic.com" && rpId == "google.com" -> {
133+
// Valid: Hardcoded support for Google putting their app id under gstatic.com.
134+
// This is gonna save us a ton of requests
135+
true
136+
}
137+
isFacetIdTrusted(context, facetId, appId) -> {
138+
// Valid: Allowed by TrustedFacets list
139+
true
140+
}
141+
else -> {
142+
false
143+
}
144+
}
145+
} catch (e: Exception) {
146+
false
147+
}
148+
}
149+
150+
suspend fun RequestOptions.checkIsValid(context: Context, facetId: String, packageName: String?) {
71151
if (type == REGISTER) {
72152
if (registerOptions.authenticatorSelection.requireResidentKey == true) {
73153
throw RequestHandlingException(
@@ -81,25 +161,46 @@ fun RequestOptions.checkIsValid(context: Context) {
81161
throw RequestHandlingException(NOT_ALLOWED_ERR, "Request doesn't have a valid list of allowed credentials.")
82162
}
83163
}
84-
if (authenticationExtensions?.fidoAppIdExtension?.appId != null) {
85-
val appId = authenticationExtensions.fidoAppIdExtension.appId
164+
if (facetId.startsWith("https://")) {
165+
if (topDomainOf(Uri.parse(facetId).host) != topDomainOf(rpId)) {
166+
throw RequestHandlingException(NOT_ALLOWED_ERR, "RP ID $rpId not allowed from facet $facetId")
167+
}
168+
// FIXME: Standard suggests doing additional checks, but this is already sensible enough
169+
} else if (facetId.startsWith("android:apk-key-hash:") && packageName != null) {
170+
val sha256FacetId = getAltFacetId(context, packageName, facetId)
171+
if (!isAssetLinked(context, rpId, sha256FacetId, packageName)) {
172+
throw RequestHandlingException(NOT_ALLOWED_ERR, "RP ID $rpId not allowed from facet $sha256FacetId")
173+
}
174+
} else if (facetId.startsWith("android:apk-key-hash-sha256:")) {
175+
if (!isAssetLinked(context, rpId, facetId, packageName)) {
176+
throw RequestHandlingException(NOT_ALLOWED_ERR, "RP ID $rpId not allowed from facet $facetId")
177+
}
178+
} else {
179+
throw RequestHandlingException(NOT_SUPPORTED_ERR, "Facet $facetId not supported")
180+
}
181+
val appId = authenticationExtensions?.fidoAppIdExtension?.appId
182+
if (appId != null) {
86183
if (!appId.startsWith("https://")) {
87-
throw RequestHandlingException(NOT_ALLOWED_ERR, "FIDO AppId must start with https://")
184+
throw RequestHandlingException(NOT_ALLOWED_ERR, "AppId $appId must start with https://")
88185
}
89-
val uri = Uri.parse(appId)
90-
if (uri.host.isNullOrEmpty()) {
91-
throw RequestHandlingException(NOT_ALLOWED_ERR, "FIDO AppId must have a valid hostname")
186+
if (Uri.parse(appId).host.isNullOrEmpty()) {
187+
throw RequestHandlingException(NOT_ALLOWED_ERR, "AppId $appId must have a valid hostname")
92188
}
93-
if (InternetDomainName.from(uri.host).topDomainUnderRegistrySuffix() != InternetDomainName.from(rpId).topDomainUnderRegistrySuffix()) {
94-
throw RequestHandlingException(NOT_ALLOWED_ERR, "FIDO AppId must be same TLD+1")
189+
val altFacetId = packageName?.let { getAltFacetId(context, it, facetId) }
190+
if (!isAppIdAllowed(context, appId, facetId, rpId) &&
191+
(altFacetId == null || !isAppIdAllowed(context, appId, altFacetId, rpId))
192+
) {
193+
throw RequestHandlingException(NOT_ALLOWED_ERR, "AppId $appId not allowed from facet $facetId/$altFacetId")
95194
}
96195
}
97196
}
98197

198+
private const val HASH_BASE64_FLAGS = Base64.NO_PADDING + Base64.NO_WRAP + Base64.URL_SAFE
199+
99200
fun RequestOptions.getWebAuthnClientData(callingPackage: String, origin: String): ByteArray {
100201
val obj = JSONObject()
101202
.put("type", webAuthnType)
102-
.put("challenge", challenge.toBase64(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE))
203+
.put("challenge", challenge.toBase64(HASH_BASE64_FLAGS))
103204
.put("androidPackageName", callingPackage)
104205
.put("tokenBinding", tokenBinding?.toJsonObject())
105206
.put("origin", origin)
@@ -111,20 +212,36 @@ fun getApplicationName(context: Context, options: RequestOptions, callingPackage
111212
else -> context.packageManager.getApplicationLabel(callingPackage).toString()
112213
}
113214

114-
fun getApkHashOrigin(context: Context, packageName: String): String {
115-
val digest = context.packageManager.getFirstSignatureDigest(packageName, "SHA-256")
215+
fun getApkKeyHashFacetId(context: Context, packageName: String): String {
216+
val digest = context.packageManager.getFirstSignatureDigest(packageName, "SHA1")
116217
?: throw RequestHandlingException(NOT_ALLOWED_ERR, "Unknown package $packageName")
117-
return "android:apk-key-hash:${digest.toBase64(Base64.NO_PADDING, Base64.NO_WRAP, Base64.URL_SAFE)}"
218+
return "android:apk-key-hash:${digest.toBase64(HASH_BASE64_FLAGS)}"
219+
}
220+
221+
fun getAltFacetId(context: Context, packageName: String, facetId: String): String {
222+
val firstSignature = context.packageManager.getSignatures(packageName).firstOrNull()
223+
?: throw RequestHandlingException(NOT_ALLOWED_ERR, "Unknown package $packageName")
224+
return when (facetId) {
225+
"android:apk-key-hash:${firstSignature.digest("SHA1").toBase64(HASH_BASE64_FLAGS)}" -> {
226+
"android:apk-key-hash-sha256:${firstSignature.digest("SHA-256").toBase64(HASH_BASE64_FLAGS)}"
227+
}
228+
"android:apk-key-hash-sha256:${firstSignature.digest("SHA-256").toBase64(HASH_BASE64_FLAGS)}" -> {
229+
"android:apk-key-hash:${firstSignature.digest("SHA1").toBase64(HASH_BASE64_FLAGS)}"
230+
}
231+
else -> {
232+
throw RequestHandlingException(NOT_ALLOWED_ERR, "Package $packageName does not match facet $facetId")
233+
}
234+
}
118235
}
119236

120-
fun getOrigin(context: Context, options: RequestOptions, callingPackage: String): String = when {
237+
fun getFacetId(context: Context, options: RequestOptions, callingPackage: String): String = when {
121238
options is BrowserRequestOptions -> {
122239
if (options.origin.scheme == null || options.origin.authority == null) {
123240
throw RequestHandlingException(NOT_ALLOWED_ERR, "Bad url ${options.origin}")
124241
}
125242
"${options.origin.scheme}://${options.origin.authority}"
126243
}
127-
else -> getApkHashOrigin(context, callingPackage)
244+
else -> getApkKeyHashFacetId(context, callingPackage)
128245
}
129246

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

play-services-fido-core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import android.os.Build
1212
import android.os.Bundle
1313
import android.util.Base64
1414
import android.util.Log
15+
import androidx.annotation.RequiresApi
1516
import androidx.appcompat.app.AppCompatActivity
16-
import androidx.core.app.OnNewIntentProvider
1717
import androidx.fragment.app.commit
1818
import androidx.lifecycle.lifecycleScope
1919
import androidx.navigation.fragment.NavHostFragment
@@ -93,30 +93,55 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
9393

9494
Log.d(TAG, "onCreate caller=$callerPackage options=$options")
9595

96-
options.checkIsValid(this)
97-
val origin = getOrigin(this, options, callerPackage)
96+
val requiresPrivilege =
97+
options is BrowserRequestOptions && !database.isPrivileged(callerPackage, callerSignature)
98+
99+
// Check if we can directly open screen lock handling
100+
if (!requiresPrivilege) {
101+
val instantTransport = transportHandlers.firstOrNull { it.isSupported && it.shouldBeUsedInstantly(options) }
102+
if (instantTransport != null && instantTransport.transport in INSTANT_SUPPORTED_TRANSPORTS) {
103+
window.setBackgroundDrawable(ColorDrawable(0))
104+
window.statusBarColor = Color.TRANSPARENT
105+
setTheme(R.style.Theme_Fido_Translucent)
106+
}
107+
}
108+
109+
setTheme(R.style.Theme_AppCompat_DayNight_NoActionBar)
110+
setContentView(R.layout.fido_authenticator_activity)
111+
112+
lifecycleScope.launchWhenCreated {
113+
handleRequest(options)
114+
}
115+
} catch (e: RequestHandlingException) {
116+
finishWithError(e.errorCode, e.message ?: e.errorCode.name)
117+
} catch (e: Exception) {
118+
Log.w(TAG, e)
119+
finishWithError(UNKNOWN_ERR, e.message ?: e.javaClass.simpleName)
120+
}
121+
}
122+
123+
@RequiresApi(24)
124+
suspend fun handleRequest(options: RequestOptions) {
125+
try {
126+
val facetId = getFacetId(this, options, callerPackage)
127+
options.checkIsValid(this, facetId, callerPackage)
98128
val appName = getApplicationName(this, options, callerPackage)
99129
val callerName = packageManager.getApplicationLabel(callerPackage).toString()
100130

101131
val requiresPrivilege =
102132
options is BrowserRequestOptions && !database.isPrivileged(callerPackage, callerSignature)
103133

104-
Log.d(TAG, "origin=$origin, appName=$appName")
134+
Log.d(TAG, "facetId=$facetId, appName=$appName")
105135

106136
// Check if we can directly open screen lock handling
107137
if (!requiresPrivilege) {
108138
val instantTransport = transportHandlers.firstOrNull { it.isSupported && it.shouldBeUsedInstantly(options) }
109139
if (instantTransport != null && instantTransport.transport in INSTANT_SUPPORTED_TRANSPORTS) {
110-
window.setBackgroundDrawable(ColorDrawable(0))
111-
window.statusBarColor = Color.TRANSPARENT
112-
setTheme(R.style.Theme_Fido_Translucent)
113140
startTransportHandling(instantTransport.transport)
114141
return
115142
}
116143
}
117144

118-
setTheme(R.style.Theme_AppCompat_DayNight_NoActionBar)
119-
setContentView(R.layout.fido_authenticator_activity)
120145
val arguments = AuthenticatorActivityFragmentData().apply {
121146
this.appName = appName
122147
this.isFirst = true

0 commit comments

Comments
 (0)