/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.credentials.playservices.controllers.CreatePublicKeyCredential
import android.util.Base64
import android.util.Log
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.exceptions.domerrors.AbortError
import androidx.credentials.exceptions.domerrors.ConstraintError
import androidx.credentials.exceptions.domerrors.DataError
import androidx.credentials.exceptions.domerrors.EncodingError
import androidx.credentials.exceptions.domerrors.InvalidStateError
import androidx.credentials.exceptions.domerrors.NetworkError
import androidx.credentials.exceptions.domerrors.NotAllowedError
import androidx.credentials.exceptions.domerrors.NotReadableError
import androidx.credentials.exceptions.domerrors.NotSupportedError
import androidx.credentials.exceptions.domerrors.SecurityError
import androidx.credentials.exceptions.domerrors.TimeoutError
import androidx.credentials.exceptions.domerrors.UnknownError
import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException
import com.google.android.gms.auth.api.identity.BeginSignInRequest
import com.google.android.gms.auth.api.identity.SignInCredential
import com.google.android.gms.fido.common.Transport
import com.google.android.gms.fido.fido2.api.common.Attachment
import com.google.android.gms.fido.fido2.api.common.AttestationConveyancePreference
import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensions
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorSelectionCriteria
import com.google.android.gms.fido.fido2.api.common.COSEAlgorithmIdentifier
import com.google.android.gms.fido.fido2.api.common.ErrorCode
import com.google.android.gms.fido.fido2.api.common.FidoAppIdExtension
import com.google.android.gms.fido.fido2.api.common.GoogleThirdPartyPaymentExtension
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialCreationOptions
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialDescriptor
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialParameters
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRpEntity
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
import com.google.android.gms.fido.fido2.api.common.ResidentKeyRequirement
import com.google.android.gms.fido.fido2.api.common.UserVerificationMethodExtension
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
/** A utility class to handle logic for the begin sign in controller. */
internal class PublicKeyCredentialControllerUtility {
companion object {
internal val JSON_KEY_CLIENT_DATA = "clientDataJSON"
internal val JSON_KEY_ATTESTATION_OBJ = "attestationObject"
internal val JSON_KEY_AUTH_DATA = "authenticatorData"
internal val JSON_KEY_SIGNATURE = "signature"
internal val JSON_KEY_USER_HANDLE = "userHandle"
internal val JSON_KEY_RESPONSE = "response"
internal val JSON_KEY_ID = "id"
internal val JSON_KEY_RAW_ID = "rawId"
internal val JSON_KEY_TYPE = "type"
internal val JSON_KEY_RPID = "rpId"
internal val JSON_KEY_CHALLENGE = "challenge"
internal val JSON_KEY_APPID = "appid"
internal val JSON_KEY_THIRD_PARTY_PAYMENT = "thirdPartyPayment"
internal val JSON_KEY_AUTH_SELECTION = "authenticatorSelection"
internal val JSON_KEY_REQUIRE_RES_KEY = "requireResidentKey"
internal val JSON_KEY_RES_KEY = "residentKey"
internal val JSON_KEY_AUTH_ATTACHMENT = "authenticatorAttachment"
internal val JSON_KEY_TIMEOUT = "timeout"
internal val JSON_KEY_EXCLUDE_CREDENTIALS = "excludeCredentials"
internal val JSON_KEY_TRANSPORTS = "transports"
internal val JSON_KEY_RP = "rp"
internal val JSON_KEY_NAME = "name"
internal val JSON_KEY_ICON = "icon"
internal val JSON_KEY_ALG = "alg"
internal val JSON_KEY_USER = "user"
internal val JSON_KEY_DISPLAY_NAME = "displayName"
internal val JSON_KEY_USER_VERIFICATION_METHOD = "userVerificationMethod"
internal val JSON_KEY_KEY_PROTECTION_TYPE = "keyProtectionType"
internal val JSON_KEY_MATCHER_PROTECTION_TYPE = "matcherProtectionType"
internal val JSON_KEY_EXTENSTIONS = "extensions"
internal val JSON_KEY_ATTESTATION = "attestation"
internal val JSON_KEY_PUB_KEY_CRED_PARAMS = "pubKeyCredParams"
internal val JSON_KEY_CLIENT_EXTENSION_RESULTS = "clientExtensionResults"
internal val JSON_KEY_RK = "rk"
internal val JSON_KEY_CRED_PROPS = "credProps"
/**
* This function converts a request json to a PublicKeyCredentialCreationOptions, where there
* should be a direct mapping from the input string to this data type. See
* [here](https://w3c.github.io/webauthn/#sctn-parseCreationOptionsFromJSON) for more details.
* This occurs in the registration, or create, flow for public key credentials.
*
* @param request a credential manager data type that holds a requestJson that is expected to
* parse completely into PublicKeyCredentialCreationOptions
* @throws JSONException If required data is not present in the requestJson
*/
@JvmStatic
fun convert(request: CreatePublicKeyCredentialRequest): PublicKeyCredentialCreationOptions {
return convertJSON(JSONObject(request.requestJson))
}
internal fun convertJSON(json: JSONObject): PublicKeyCredentialCreationOptions {
val builder = PublicKeyCredentialCreationOptions.Builder()
parseRequiredChallengeAndUser(json, builder)
parseRequiredRpAndParams(json, builder)
parseOptionalWithRequiredDefaultsAttestationAndExcludeCredentials(json, builder)
parseOptionalTimeout(json, builder)
parseOptionalAuthenticatorSelection(json, builder)
parseOptionalExtensions(json, builder)
return builder.build()
}
internal fun addAuthenticatorAttestationResponse(
clientDataJSON: ByteArray,
attestationObject: ByteArray,
transportArray: Array<out String>,
json: JSONObject
) {
val responseJson = JSONObject()
responseJson.put(JSON_KEY_CLIENT_DATA, b64Encode(clientDataJSON))
responseJson.put(JSON_KEY_ATTESTATION_OBJ, b64Encode(attestationObject))
responseJson.put(JSON_KEY_TRANSPORTS, JSONArray(transportArray))
json.put(JSON_KEY_RESPONSE, responseJson)
}
fun toAssertPasskeyResponse(cred: SignInCredential): String {
var json = JSONObject()
val publicKeyCred = cred.publicKeyCredential
when (val authenticatorResponse = publicKeyCred?.response!!) {
is AuthenticatorErrorResponse -> {
throw beginSignInPublicKeyCredentialResponseContainsError(
authenticatorResponse.errorCode,
authenticatorResponse.errorMessage
)
}
is AuthenticatorAssertionResponse -> {
try {
return publicKeyCred.toJson()
} catch (t: Throwable) {
throw GetCredentialUnknownException("The PublicKeyCredential response json had " +
"an unexpected exception when parsing: ${t.message}")
}
}
else -> {
Log.e(
TAG,
"AuthenticatorResponse expected assertion response but " +
"got: ${authenticatorResponse.javaClass.name}"
)
}
}
return json.toString()
}
/**
* Converts from the Credential Manager public key credential option to the Play Auth Module
* passkey json option.
*
* @return the current auth module passkey request
*/
fun convertToPlayAuthPasskeyJsonRequest(
option: GetPublicKeyCredentialOption
): BeginSignInRequest.PasskeyJsonRequestOptions {
return BeginSignInRequest.PasskeyJsonRequestOptions.Builder()
.setSupported(true)
.setRequestJson(option.requestJson)
.build()
}
/**
* Converts from the Credential Manager public key credential option to the Play Auth Module
* passkey option, used in a backwards compatible flow for the auth dependency.
*
* @return the backwards compatible auth module passkey request
*/
@Deprecated("Upgrade GMS version so 'convertToPlayAuthPasskeyJsonRequest' is used")
@Suppress("deprecation")
fun convertToPlayAuthPasskeyRequest(
option: GetPublicKeyCredentialOption
): BeginSignInRequest.PasskeysRequestOptions {
val json = JSONObject(option.requestJson)
val rpId = json.optString(JSON_KEY_RPID, "")
if (rpId.isEmpty()) {
throw JSONException(
"GetPublicKeyCredentialOption - rpId not specified in the " +
"request or is unexpectedly empty"
)
}
val challenge = getChallenge(json)
return BeginSignInRequest.PasskeysRequestOptions.Builder()
.setSupported(true)
.setRpId(rpId)
.setChallenge(challenge)
.build()
}
private fun getChallenge(json: JSONObject): ByteArray {
val challengeB64 = json.optString(JSON_KEY_CHALLENGE, "")
if (challengeB64.isEmpty()) {
throw JSONException("Challenge not found in request or is unexpectedly empty")
}
return b64Decode(challengeB64)
}
/**
* Indicates if an error was propagated from the underlying Fido API.
*
* @param cred the public key credential response object from fido
* @return an exception if it exists, else null indicating no exception
*/
fun publicKeyCredentialResponseContainsError(
cred: PublicKeyCredential
): CreateCredentialException? {
val authenticatorResponse: AuthenticatorResponse = cred.response
if (authenticatorResponse is AuthenticatorErrorResponse) {
val code = authenticatorResponse.errorCode
var exceptionError = orderedErrorCodeToExceptions[code]
var msg = authenticatorResponse.errorMessage
val exception: CreateCredentialException
if (exceptionError == null) {
exception =
CreatePublicKeyCredentialDomException(
UnknownError(),
"unknown fido gms exception - $msg"
)
} else {
// This fix is quite fragile because it relies on that the fido module
// does not change its error message, but is the only viable solution
// because there's no other differentiator.
if (
code == ErrorCode.CONSTRAINT_ERR && msg?.contains("Unable to get sync account") == true
) {
exception =
CreateCredentialCancellationException(
"Passkey registration was cancelled by the user."
)
} else {
exception = CreatePublicKeyCredentialDomException(exceptionError, msg)
}
}
return exception
}
return null
}
// Helper method for the begin sign in flow to identify an authenticator error response
internal fun beginSignInPublicKeyCredentialResponseContainsError(
code: ErrorCode,
msg: String?,
): GetCredentialException {
var exceptionError = orderedErrorCodeToExceptions[code]
val exception: GetCredentialException
if (exceptionError == null) {
exception =
GetPublicKeyCredentialDomException(UnknownError(), "unknown fido gms exception - $msg")
} else {
// This fix is quite fragile because it relies on that the fido module
// does not change its error message, but is the only viable solution
// because there's no other differentiator.
if (
code == ErrorCode.CONSTRAINT_ERR && msg?.contains("Unable to get sync account") == true
) {
exception =
GetCredentialCancellationException("Passkey retrieval was cancelled by the user.")
} else {
exception = GetPublicKeyCredentialDomException(exceptionError, msg)
}
}
return exception
}
internal fun parseOptionalExtensions(
json: JSONObject,
builder: PublicKeyCredentialCreationOptions.Builder
) {
if (json.has(JSON_KEY_EXTENSTIONS)) {
val extensions = json.getJSONObject(JSON_KEY_EXTENSTIONS)
val extensionBuilder = AuthenticationExtensions.Builder()
val appIdExtension = extensions.optString(JSON_KEY_APPID, "")
if (appIdExtension.isNotEmpty()) {
extensionBuilder.setFido2Extension(FidoAppIdExtension(appIdExtension))
}
val thirdPartyPaymentExtension = extensions.optBoolean(JSON_KEY_THIRD_PARTY_PAYMENT, false)
if (thirdPartyPaymentExtension) {
extensionBuilder.setGoogleThirdPartyPaymentExtension(
GoogleThirdPartyPaymentExtension(true)
)
}
val uvmStatus = extensions.optBoolean("uvm", false)
if (uvmStatus) {
extensionBuilder.setUserVerificationMethodExtension(UserVerificationMethodExtension(true))
}
builder.setAuthenticationExtensions(extensionBuilder.build())
}
}
internal fun parseOptionalAuthenticatorSelection(
json: JSONObject,
builder: PublicKeyCredentialCreationOptions.Builder
) {
if (json.has(JSON_KEY_AUTH_SELECTION)) {
val authenticatorSelection = json.getJSONObject(JSON_KEY_AUTH_SELECTION)
val authSelectionBuilder = AuthenticatorSelectionCriteria.Builder()
val requireResidentKey = authenticatorSelection.optBoolean(JSON_KEY_REQUIRE_RES_KEY, false)
val residentKey = authenticatorSelection.optString(JSON_KEY_RES_KEY, "")
var residentKeyRequirement: ResidentKeyRequirement? = null
if (residentKey.isNotEmpty()) {
residentKeyRequirement = ResidentKeyRequirement.fromString(residentKey)
}
authSelectionBuilder
.setRequireResidentKey(requireResidentKey)
.setResidentKeyRequirement(residentKeyRequirement)
val authenticatorAttachmentString =
authenticatorSelection.optString(JSON_KEY_AUTH_ATTACHMENT, "")
if (authenticatorAttachmentString.isNotEmpty()) {
authSelectionBuilder.setAttachment(Attachment.fromString(authenticatorAttachmentString))
}
builder.setAuthenticatorSelection(authSelectionBuilder.build())
}
}
internal fun parseOptionalTimeout(
json: JSONObject,
builder: PublicKeyCredentialCreationOptions.Builder
) {
if (json.has(JSON_KEY_TIMEOUT)) {
val timeout = json.getLong(JSON_KEY_TIMEOUT).toDouble() / 1000
builder.setTimeoutSeconds(timeout)
}
}
internal fun parseOptionalWithRequiredDefaultsAttestationAndExcludeCredentials(
json: JSONObject,
builder: PublicKeyCredentialCreationOptions.Builder
) {
val excludeCredentialsList: MutableList<PublicKeyCredentialDescriptor> = ArrayList()
if (json.has(JSON_KEY_EXCLUDE_CREDENTIALS)) {
val pubKeyDescriptorJSONs = json.getJSONArray(JSON_KEY_EXCLUDE_CREDENTIALS)
for (i in 0 until pubKeyDescriptorJSONs.length()) {
val descriptorJSON = pubKeyDescriptorJSONs.getJSONObject(i)
val descriptorId = b64Decode(descriptorJSON.getString(JSON_KEY_ID))
val descriptorType = descriptorJSON.getString(JSON_KEY_TYPE)
if (descriptorType.isEmpty()) {
throw JSONException(
"PublicKeyCredentialDescriptor type value is not " + "found or unexpectedly empty"
)
}
if (descriptorId.isEmpty()) {
throw JSONException(
"PublicKeyCredentialDescriptor id value is not " + "found or unexpectedly empty"
)
}
var transports: MutableList<Transport>? = null
if (descriptorJSON.has(JSON_KEY_TRANSPORTS)) {
transports = ArrayList()
val descriptorTransports = descriptorJSON.getJSONArray(JSON_KEY_TRANSPORTS)
for (j in 0 until descriptorTransports.length()) {
try {
transports.add(Transport.fromString(descriptorTransports.getString(j)))
} catch (e: Transport.UnsupportedTransportException) {
throw CreatePublicKeyCredentialDomException(EncodingError(), e.message)
}
}
}
excludeCredentialsList.add(
PublicKeyCredentialDescriptor(descriptorType, descriptorId, transports)
)
}
}
builder.setExcludeList(excludeCredentialsList)
var attestationString = json.optString(JSON_KEY_ATTESTATION, "none")
if (attestationString.isEmpty()) {
attestationString = "none"
}
builder.setAttestationConveyancePreference(
AttestationConveyancePreference.fromString(attestationString)
)
}
internal fun parseRequiredRpAndParams(
json: JSONObject,
builder: PublicKeyCredentialCreationOptions.Builder
) {
val rp = json.getJSONObject(JSON_KEY_RP)
val rpId = rp.getString(JSON_KEY_ID)
val rpName = rp.optString(JSON_KEY_NAME, "")
var rpIcon: String? = rp.optString(JSON_KEY_ICON, "")
if (rpIcon!!.isEmpty()) {
rpIcon = null
}
if (rpName.isEmpty()) {
throw JSONException(
"PublicKeyCredentialCreationOptions rp name is " + "missing or unexpectedly empty"
)
}
if (rpId.isEmpty()) {
throw JSONException(
"PublicKeyCredentialCreationOptions rp ID is " + "missing or unexpectedly empty"
)
}
builder.setRp(PublicKeyCredentialRpEntity(rpId, rpName, rpIcon))
val pubKeyCredParams = json.getJSONArray(JSON_KEY_PUB_KEY_CRED_PARAMS)
val paramsList: MutableList<PublicKeyCredentialParameters> = ArrayList()
for (i in 0 until pubKeyCredParams.length()) {
val param = pubKeyCredParams.getJSONObject(i)
val paramAlg = param.getLong(JSON_KEY_ALG).toInt()
val typeParam = param.optString(JSON_KEY_TYPE, "")
if (typeParam.isEmpty()) {
throw JSONException(
"PublicKeyCredentialCreationOptions " +
"PublicKeyCredentialParameter type missing or unexpectedly empty"
)
}
if (checkAlgSupported(paramAlg)) {
paramsList.add(PublicKeyCredentialParameters(typeParam, paramAlg))
}
}
builder.setParameters(paramsList)
}
internal fun parseRequiredChallengeAndUser(
json: JSONObject,
builder: PublicKeyCredentialCreationOptions.Builder
) {
val challenge = getChallenge(json)
builder.setChallenge(challenge)
val user = json.getJSONObject(JSON_KEY_USER)
val userId = b64Decode(user.getString(JSON_KEY_ID))
val userName = user.getString(JSON_KEY_NAME)
val displayName = user.getString(JSON_KEY_DISPLAY_NAME)
val userIcon = user.optString(JSON_KEY_ICON, "")
if (displayName.isEmpty()) {
throw JSONException(
"PublicKeyCredentialCreationOptions UserEntity missing " +
"displayName or they are unexpectedly empty"
)
}
if (userId.isEmpty()) {
throw JSONException(
"PublicKeyCredentialCreationOptions UserEntity missing " +
"user id or they are unexpectedly empty"
)
}
if (userName.isEmpty()) {
throw JSONException(
"PublicKeyCredentialCreationOptions UserEntity missing " +
"user name or they are unexpectedly empty"
)
}
builder.setUser(PublicKeyCredentialUserEntity(userId, userName, userIcon, displayName))
}
/**
* Decode specific to public key credential encoded strings, or any string that requires
* NO_PADDING, NO_WRAP and URL_SAFE flags for base 64 decoding.
*
* @param str the string the decode into a bytearray
*/
fun b64Decode(str: String): ByteArray {
return Base64.decode(str, FLAGS)
}
/**
* Encode specific to public key credential decoded strings, or any string that requires
* NO_PADDING, NO_WRAP and URL_SAFE flags for base 64 encoding.
*
* @param data the bytearray to encode into a string
*/
fun b64Encode(data: ByteArray): String {
return Base64.encodeToString(data, FLAGS)
}
/**
* Some values are not supported in the webauthn spec - this catches those values and returns
* false - otherwise it returns true.
*
* @param alg the int code of the cryptography algorithm used in the webauthn flow
*/
fun checkAlgSupported(alg: Int): Boolean {
try {
COSEAlgorithmIdentifier.fromCoseValue(alg)
return true
} catch (_: Throwable) {}
return false
}
private const val FLAGS = Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING
private const val TAG = "PublicKeyUtility"
internal val orderedErrorCodeToExceptions =
linkedMapOf(
ErrorCode.UNKNOWN_ERR to UnknownError(),
ErrorCode.ABORT_ERR to AbortError(),
ErrorCode.ATTESTATION_NOT_PRIVATE_ERR to NotReadableError(),
ErrorCode.CONSTRAINT_ERR to ConstraintError(),
ErrorCode.DATA_ERR to DataError(),
ErrorCode.INVALID_STATE_ERR to InvalidStateError(),
ErrorCode.ENCODING_ERR to EncodingError(),
ErrorCode.NETWORK_ERR to NetworkError(),
ErrorCode.NOT_ALLOWED_ERR to NotAllowedError(),
ErrorCode.NOT_SUPPORTED_ERR to NotSupportedError(),
ErrorCode.SECURITY_ERR to SecurityError(),
ErrorCode.TIMEOUT_ERR to TimeoutError()
)
}
}