@@ -8,17 +8,19 @@ package org.microg.gms.fido.core
88import android.content.Context
99import android.net.Uri
1010import android.util.Base64
11+ import com.android.volley.toolbox.JsonArrayRequest
12+ import com.android.volley.toolbox.JsonObjectRequest
13+ import com.android.volley.toolbox.Volley
1114import com.google.android.gms.fido.fido2.api.common.*
1215import com.google.android.gms.fido.fido2.api.common.ErrorCode.*
1316import 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
1619import org.json.JSONObject
1720import org.microg.gms.fido.core.RequestOptionsType.REGISTER
1821import 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
2224import java.security.MessageDigest
2325
2426class RequestHandlingException (val errorCode : ErrorCode , message : String? = null ) : Exception(message)
@@ -42,7 +44,7 @@ val RequestOptions.signOptions: PublicKeyCredentialRequestOptions
4244val 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
6769val 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+
99200fun 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
130247fun 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()
0 commit comments