Credential Manager

This commit is contained in:
Marvin W 2026-03-30 01:03:03 +02:00
parent 587d693962
commit 94a488be5c
No known key found for this signature in database
GPG key ID: 072E9235DB996F2A
44 changed files with 1572 additions and 101 deletions

View file

@ -14,6 +14,7 @@ buildscript {
ext.appcompatVersion = '1.6.1'
ext.biometricVersion = '1.1.0'
ext.coreVersion = '1.12.0'
ext.credentialsVersion = '1.2.0'
ext.fragmentVersion = '1.6.2'
ext.lifecycleVersion = '2.7.0'
ext.loaderVersion = '1.1.0'
@ -123,4 +124,3 @@ subprojects {
if (hasModule("hms", false)) maven {url 'https://developer.huawei.com/repo/'}
}
}

View file

@ -1,12 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 microG Project Team
* SPDX-License-Identifier: CC-BY-4.0
* Notice: Portions of this file are reproduced from work created and shared by Google and used
* according to terms described in the Creative Commons 4.0 Attribution License.
* See https://developers.google.com/readme/policies for details.
*/
/**
* Provides facilities to retrieve or save credentials that can be used to
sign the user into your application or sign up a new user.
*/
package com.google.android.gms.auth.api.identity;

View file

@ -115,8 +115,132 @@ public class BeginSignInRequest extends AbstractSafeParcelable {
return preferImmediatelyAvailableCredentials;
}
/**
* Builder for {@link BeginSignInRequest}.
*/
public static class Builder {
private PasswordRequestOptions passwordRequestOptions;
private GoogleIdTokenRequestOptions googleIdTokenRequestOptions;
private String sessionId;
private boolean autoSelectEnabled;
private int theme;
private PasskeysRequestOptions passkeysRequestOptions;
private PasskeyJsonRequestOptions passkeyJsonRequestOptions;
private boolean preferImmediatelyAvailableCredentials;
/**
* Returns the built {@link BeginSignInRequest}.
*/
@NonNull
public BeginSignInRequest build() {
return new BeginSignInRequest(
passwordRequestOptions,
googleIdTokenRequestOptions,
sessionId,
autoSelectEnabled,
theme,
passkeysRequestOptions,
passkeyJsonRequestOptions,
preferImmediatelyAvailableCredentials
);
}
/**
* Sets whether to enable auto-select for the credential.
* <p>
* If enabled and only one credential is available, it will be automatically selected.
*
* @param autoSelectEnabled whether to enable auto-select
*/
@NonNull
public Builder setAutoSelectEnabled(boolean autoSelectEnabled) {
this.autoSelectEnabled = autoSelectEnabled;
return this;
}
/**
* Sets options for requesting Google ID token-backed credentials.
*
* @param googleIdTokenRequestOptions the Google ID token request options
*/
@NonNull
public Builder setGoogleIdTokenRequestOptions(@Nullable GoogleIdTokenRequestOptions googleIdTokenRequestOptions) {
this.googleIdTokenRequestOptions = googleIdTokenRequestOptions;
return this;
}
/**
* Sets options for requesting passkey credentials using JSON format.
*
* @param passkeyJsonRequestOptions the passkey JSON request options
*/
@NonNull
public Builder setPasskeyJsonRequestOptions(@Nullable PasskeyJsonRequestOptions passkeyJsonRequestOptions) {
this.passkeyJsonRequestOptions = passkeyJsonRequestOptions;
return this;
}
/**
* Sets options for requesting passkey credentials.
*
* @param passkeysRequestOptions the passkey request options
* @deprecated Use {@link #setPasskeyJsonRequestOptions(PasskeyJsonRequestOptions)} instead
*/
@Deprecated
@NonNull
public Builder setPasskeysRequestOptions(@Nullable PasskeysRequestOptions passkeysRequestOptions) {
this.passkeysRequestOptions = passkeysRequestOptions;
return this;
}
/**
* Sets options for requesting password-backed credentials.
*
* @param passwordRequestOptions the password request options
*/
@NonNull
public Builder setPasswordRequestOptions(@Nullable PasswordRequestOptions passwordRequestOptions) {
this.passwordRequestOptions = passwordRequestOptions;
return this;
}
/**
* Sets whether to prefer immediately available credentials.
* <p>
* If true, the API will only return credentials that are immediately available
* without requiring user interaction.
*
* @param preferImmediatelyAvailableCredentials whether to prefer immediately available credentials
*/
@NonNull
public Builder setPreferImmediatelyAvailableCredentials(boolean preferImmediatelyAvailableCredentials) {
this.preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials;
return this;
}
/**
* Sets the session ID for this sign-in request.
*
* @param sessionId the session ID
*/
@Hide
@NonNull
public Builder setSessionId(@Nullable String sessionId) {
this.sessionId = sessionId;
return this;
}
/**
* Sets the theme for the sign-in UI.
*
* @param theme the theme resource ID
*/
@Hide
@NonNull
public Builder setTheme(int theme) {
this.theme = theme;
return this;
}
}
/**

View file

@ -103,6 +103,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "androidx.credentials:credentials:$credentialsVersion"
implementation "androidx.work:work-runtime-ktx:$workVersion"
}

View file

@ -147,6 +147,8 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.PROVIDE_REMOTE_CREDENTIALS" />
<uses-permission android:name="android.permission.PROVIDE_DEFAULT_ENABLED_CREDENTIAL_SERVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
@ -185,6 +187,7 @@
tools:overrideLibrary="androidx.compose.ui.tooling,
androidx.compose.material3,
androidx.activity.compose,
androidx.credentials,
androidx.compose.material.icons,
androidx.compose.material.ripple,
androidx.compose.foundation,
@ -674,6 +677,70 @@
android:exported="true"
android:theme="@style/Theme.LoginBlue"/>
<!-- Credential Provider Services -->
<service
android:name="com.google.android.gms.auth.api.credentials.credman.service.PasswordAndPasskeyService"
android:exported="true"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
tools:targetApi="34">
<intent-filter>
<action android:name="android.service.credentials.CredentialProviderService"/>
</intent-filter>
<meta-data
android:name="android.credentials.provider"
android:resource="@xml/credentials_provider_passkey" />
</service>
<service
android:name="com.google.android.gms.auth.api.credentials.credman.service.RemoteService"
android:exported="true"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
tools:targetApi="34">
<intent-filter>
<action android:name="android.service.credentials.system.CredentialProviderService"/>
</intent-filter>
<meta-data
android:name="android.credentials.provider"
android:resource="@xml/credentials_provider_remote" />
</service>
<service
android:name="com.google.android.gms.auth.api.credentials.credman.service.GoogleIdService"
android:exported="true"
android:icon="@drawable/ic_google_logo"
android:label="@string/credentials_service_sign_in_with_google_label"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
tools:targetApi="34">
<intent-filter>
<action android:name="android.service.credentials.system.CredentialProviderService"/>
</intent-filter>
<meta-data
android:name="android.credentials.provider"
android:resource="@xml/credentials_provider_google_id" />
</service>
<activity
android:name="org.microg.gms.auth.credentials.provider.PublicKeyProxyActivity"
android:exported="false"
android:process=":ui"
android:theme="@style/Theme.App.Translucent"
android:excludeFromRecents="true"
tools:targetApi="34" />
<activity
android:name="org.microg.gms.auth.credentials.provider.SignInProxyActivity"
android:exported="false"
android:process=":ui"
android:theme="@style/Theme.App.Translucent"
android:excludeFromRecents="true"
tools:targetApi="34">
<intent-filter>
<action android:name="org.microg.gms.auth.credentials.GOOGLE_ID_PROXY_INTENT"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<!-- Games -->
<activity

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.api.credentials.credman.service
import org.microg.gms.auth.credentials.provider.GoogleIdService
class GoogleIdService : GoogleIdService()

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.api.credentials.credman.service
import org.microg.gms.auth.credentials.provider.PasswordAndPasskeyService
class PasswordAndPasskeyService : PasswordAndPasskeyService()

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.gms.auth.api.credentials.credman.service
import org.microg.gms.auth.credentials.provider.RemoteService
class RemoteService : RemoteService()

View file

@ -16,6 +16,7 @@ import com.google.android.gms.fido.Fido.FIDO2_KEY_CREDENTIAL_EXTRA
import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredential
import org.microg.gms.auth.AuthConstants
import org.microg.gms.fido.core.ui.ACTION_FIDO_AUTHENTICATE
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_CALLER
private const val REQUEST_CODE = 1586077619
@ -23,7 +24,7 @@ private const val REQUEST_CODE = 1586077619
class IdentityFidoProxyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivityForResult(Intent("org.microg.gms.fido.AUTHENTICATE").apply {
startActivityForResult(Intent(ACTION_FIDO_AUTHENTICATE).apply {
`package` = packageName
putExtras(intent.extras ?: Bundle())
putExtra(KEY_CALLER, callingActivity?.packageName)

View file

@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.credentials.provider
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.credentials.CreateCredentialResponse
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.ProviderCreateCredentialRequest
import androidx.credentials.provider.ProviderGetCredentialRequest
private const val TAG = "CredentialProviderActivity"
abstract class CredentialProviderActivity : AppCompatActivity() {
var isCreateRequest = false
private set
var isGetRequest = false
private set
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate: intent: ${intent?.extras?.keySet()}")
runCatching {
val createRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
if (createRequest != null) {
isCreateRequest = true
return onProviderCreateCredentialRequest(createRequest)
}
val getRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
if (getRequest != null) {
isGetRequest = true
return onProviderGetCredentialRequest(getRequest)
}
finishWithException("Unknown request")
}.onFailure { e ->
Log.e(TAG, "Error handling passkey", e)
finishWithException(e.localizedMessage)
}
}
// TODO: Turn these into suspendable and make sure that returning the correct result type becomes mandatory
abstract fun onProviderCreateCredentialRequest(request: ProviderCreateCredentialRequest)
abstract fun onProviderGetCredentialRequest(request: ProviderGetCredentialRequest)
fun finishWithSuccess(response: CreateCredentialResponse) {
if (!isCreateRequest) return finishWithException()
setResult(
RESULT_OK,
Intent().also { PendingIntentHandler.setCreateCredentialResponse(it, response) }
)
finish()
}
fun finishWithSuccess(response: GetCredentialResponse) {
if (!isGetRequest) return finishWithException()
setResult(
RESULT_OK,
Intent().also { PendingIntentHandler.setGetCredentialResponse(it, response) }
)
finish()
}
fun finishWithException(
message: String? = null,
createExceptionCreator: (String?) -> CreateCredentialException = { CreateCredentialUnknownException(it) },
getExceptionCreator: (String?) -> GetCredentialException = { GetCredentialUnknownException(it) }
) {
when {
isCreateRequest -> {
setResult(
RESULT_OK,
Intent().also {
PendingIntentHandler.setCreateCredentialException(
it,
createExceptionCreator(message)
)
}
)
}
isGetRequest -> {
setResult(
RESULT_OK,
Intent().also { PendingIntentHandler.setGetCredentialException(it, getExceptionCreator(message)) }
)
}
else -> {
if (message != null) Log.w(TAG, message)
setResult(RESULT_CANCELED)
}
}
finish()
}
}

View file

@ -0,0 +1,200 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.credentials.provider
import android.accounts.AccountManager
import android.app.PendingIntent
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Bundle
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginGetCredentialOption
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.CredentialEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.CustomCredentialEntry
import com.google.android.gms.R
import org.microg.gms.auth.credentials.provider.GoogleIdRequestParams.Companion.toGoogleIdRequestParams
import org.microg.gms.auth.AuthConstants
private const val TAG = "GoogleIdService"
/**
* Google ID Credential Provider Service
* Handles Google Sign-In and Google ID Token credentials
* Note: This service only handles GET operations (sign-in), not CREATE operations
*/
@RequiresApi(34)
open class GoogleIdService : CredentialProviderService() {
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>
) {
runCatching {
val credentialEntries = request.beginGetCredentialOptions
.flatMap { option ->
when (option.type) {
TYPE_GOOGLE_ID_TOKEN_CREDENTIAL -> handleGoogleIdTokenRequest(option, request)
else -> emptyList<CredentialEntry>().also {
Log.d(TAG, "Unsupported credential type: ${option.type}")
}
}
}
callback.onResult(BeginGetCredentialResponse.Builder()
.setCredentialEntries(credentialEntries)
.build()
.also { Log.d(TAG, "Returning ${credentialEntries.size} credential entries") })
}.onFailure { e ->
Log.e(TAG, "Error in onBeginGetCredential", e)
callback.onError(GetCredentialUnknownException(e.message))
}
}
private fun handleGoogleIdTokenRequest(
option: BeginGetCredentialOption,
request: BeginGetCredentialRequest
): List<CredentialEntry> = option.candidateQueryData.toGoogleIdRequestParams().let { params ->
val callingPackage = request.callingAppInfo?.packageName.orEmpty()
val accounts = AccountManager.get(this).getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE)
if (accounts.isEmpty()) {
listOf(createCredentialEntry(
option = option,
params = params,
callingPackage = callingPackage,
title = getString(R.string.credentials_service_sign_in_with_google_label),
requestCode = TAG.hashCode(),
))
} else {
accounts.map { account ->
createCredentialEntry(
option = option,
params = params,
callingPackage = callingPackage,
accountName = account.name,
title = account.name,
requestCode = account.name.hashCode()
)
}
}
}
private fun createCredentialEntry(
option: BeginGetCredentialOption,
params: GoogleIdRequestParams,
callingPackage: String,
accountName: String? = null,
title: String,
requestCode: Int
): CredentialEntry {
val intent = createGoogleIdIntent(params, callingPackage, accountName)
val pendingIntent = PendingIntent.getActivity(
this,
requestCode,
intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return CustomCredentialEntry(
this,
title,
pendingIntent,
option,
accountName?.takeIf { it != title },
null,
null,
Icon.createWithResource(this, R.drawable.ic_google_logo)
)
}
private fun createGoogleIdIntent(
params: GoogleIdRequestParams,
callingPackage: String,
accountName: String?
): Intent = Intent(this, SignInProxyActivity::class.java).apply {
accountName?.let { putExtra(GOOGLE_ID_SIWG_ACCOUNT_NAME, it) }
putExtra(GOOGLE_ID_SIWG_SERVER_CLIENT_ID, params.serverClientId ?: "")
putExtra(GOOGLE_ID_SIWG_NONCE, params.nonce)
putExtra(GOOGLE_ID_SIWG_CALLER_PACKAGE, callingPackage)
}
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
) {
// Google ID Service only handles GET (sign-in), not CREATE
callback.onResult(BeginCreateCredentialResponse.Builder().build())
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>
) {
callback.onResult(null)
}
}
const val GOOGLE_ID_SUBTYPE = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_GOOGLE_ID_TOKEN_SUBTYPE"
const val GOOGLE_ID_TYPE_SIWG = "com.google.android.libraries.identity.googleid.TYPE_GOOGLE_ID_TOKEN_SIWG_CREDENTIAL"
const val GOOGLE_ID_SERVER_CLIENT_ID = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_SERVER_CLIENT_ID"
const val GOOGLE_ID_NONCE = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_NONCE"
const val GOOGLE_ID_LINKED_SERVICE_ID = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_LINKED_SERVICE_ID"
const val GOOGLE_ID_REQUEST_VERIFIED_PHONE = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_REQUEST_VERIFIED_PHONE_NUMBER"
const val GOOGLE_ID_SIWG_CALLER_PACKAGE = "com.google.android.libraries.identity.googleid.siwg.BUNDLE_KEY_CALLER_PACKAGE"
const val GOOGLE_ID_SIWG_ACCOUNT_NAME = "com.google.android.libraries.identity.googleid.siwg.BUNDLE_KEY_ACCOUNT_NAME"
const val GOOGLE_ID_SIWG_SERVER_CLIENT_ID = "com.google.android.libraries.identity.googleid.siwg.BUNDLE_KEY_SERVER_CLIENT_ID"
const val GOOGLE_ID_SIWG_NONCE = "com.google.android.libraries.identity.googleid.siwg.BUNDLE_KEY_NONCE"
const val GOOGLE_ID_SIWG_HOSTED_DOMAIN = "com.google.android.libraries.identity.googleid.siwg.BUNDLE_KEY_HOSTED_DOMAIN_FILTER"
const val GOOGLE_ID_ANDROIDX_AUTO_SELECT = "androidx.credentials.BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED"
const val GOOGLE_ID_BUNDLE_KEY_ID = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID"
const val GOOGLE_ID_BUNDLE_KEY_ID_TOKEN = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_ID_TOKEN"
const val GOOGLE_ID_BUNDLE_KEY_DISPLAY_NAME = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_DISPLAY_NAME"
const val GOOGLE_ID_BUNDLE_KEY_PROFILE_PICTURE_URI = "com.google.android.libraries.identity.googleid.BUNDLE_KEY_PROFILE_PICTURE_URI"
// Credential types
const val TYPE_GOOGLE_ID_TOKEN_CREDENTIAL = "com.google.android.libraries.identity.googleid.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL"
data class GoogleIdRequestParams(
val isSignInWithGoogle: Boolean,
val serverClientId: String?,
val nonce: String?,
val hostedDomainFilter: String?,
val isAutoSelectAllowed: Boolean = false,
val filterByAuthorized: Boolean = false,
val linkedServiceId: String?,
val requestVerifiedPhoneNumber: Boolean = false
) {
companion object {
fun Bundle.toGoogleIdRequestParams(): GoogleIdRequestParams {
val isSignInWithGoogle = getString(GOOGLE_ID_SUBTYPE) == GOOGLE_ID_TYPE_SIWG
return GoogleIdRequestParams(
isSignInWithGoogle = isSignInWithGoogle,
serverClientId = getString(if (isSignInWithGoogle) GOOGLE_ID_SIWG_SERVER_CLIENT_ID else GOOGLE_ID_SERVER_CLIENT_ID),
nonce = getString(if (isSignInWithGoogle) GOOGLE_ID_SIWG_NONCE else GOOGLE_ID_NONCE),
hostedDomainFilter = getString(GOOGLE_ID_SIWG_HOSTED_DOMAIN),
isAutoSelectAllowed = getBoolean(GOOGLE_ID_ANDROIDX_AUTO_SELECT, false),
linkedServiceId = getString(GOOGLE_ID_LINKED_SERVICE_ID),
requestVerifiedPhoneNumber = getBoolean(GOOGLE_ID_REQUEST_VERIFIED_PHONE, false)
)
}
}
}

View file

@ -0,0 +1,257 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.credentials.provider
import android.util.Base64
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.AuthenticationExtensionsClientOutputs
import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensionsCredPropsOutputs
import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensionsPrfOutputs
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorSelectionCriteria
import com.google.android.gms.fido.fido2.api.common.FidoAppIdExtension
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.PublicKeyCredentialRequestOptions
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.TokenBinding
import com.google.android.gms.fido.fido2.api.common.TokenBinding.TokenBindingStatus
import com.google.android.gms.fido.fido2.api.common.UserVerificationRequirement
import com.google.android.gms.fido.fido2.api.common.UvmEntries
import com.google.android.gms.fido.fido2.api.common.UvmEntry
import org.json.JSONArray
import org.json.JSONObject
import org.microg.gms.fido.core.protocol.AttestationObject
import org.microg.gms.fido.core.protocol.AuthenticatorData
import org.microg.gms.fido.core.protocol.CoseKey
const val BASE64_URL_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
fun String.decodeBase64Url(): ByteArray = Base64.decode(this, BASE64_URL_FLAGS)
fun ByteArray.encodeBase64Url(): String = Base64.encodeToString(this, BASE64_URL_FLAGS)
fun PublicKeyCredential.toJson() = toJsonObject().toString()
fun PublicKeyCredential.toJsonObject() = JSONObject().apply {
val response = response
val id = id
val rawId = rawId
val clientExtensionResults = clientExtensionResults
if (id != null) put("id", id)
if (rawId != null && rawId.isNotEmpty()) put("rawId", rawId.encodeBase64Url())
when (response) {
is AuthenticatorAttestationResponse -> put("response", response.toJsonObject())
is AuthenticatorAssertionResponse -> put("response", response.toJsonObject())
is AuthenticatorErrorResponse -> put("error", response.toJsonObject())
}
if (authenticatorAttachment != null) put("authenticatorAttachment", authenticatorAttachment)
put("clientExtensionResults", clientExtensionResults?.toJsonObject() ?: JSONObject())
put("type", type)
}
fun AuthenticatorAttestationResponse.toJsonObject() = JSONObject().apply {
val decodedAttestationObject = AttestationObject.decode(attestationObject)
val decodedAuthenticatorData = AuthenticatorData.decode(decodedAttestationObject.authData)
val publicKey = decodedAuthenticatorData.attestedCredentialData?.publicKey?.let { CoseKey.decode(it) }
?: throw IllegalArgumentException("Missing publicKey")
put("clientDataJSON", clientDataJSON.encodeBase64Url())
put("authenticatorData", decodedAttestationObject.authData.encodeBase64Url())
put("transports", JSONArray(transports.asList().map { if (it == Transport.HYBRID.toString()) "hybrid" else it }))
put("publicKey", publicKey.asCryptoKey()?.encoded?.encodeBase64Url())
put("publicKeyAlgorithm", publicKey.algorithm.algoValue.toLong())
put("attestationObject", attestationObject.encodeBase64Url())
}
fun AuthenticatorAssertionResponse.toJsonObject() = JSONObject().apply {
val userHandle = userHandle
put("clientDataJSON", clientDataJSON.encodeBase64Url())
put("authenticatorData", authenticatorData.encodeBase64Url())
put("signature", signature.encodeBase64Url())
if (userHandle != null) put("userHandle", userHandle.encodeBase64Url())
}
fun AuthenticatorErrorResponse.toJsonObject() = JSONObject().apply {
val errorMessage = errorMessage
put("code", errorCodeAsInt)
if (errorMessage != null) put("message", errorMessage)
}
fun AuthenticationExtensionsClientOutputs.toJsonObject() = JSONObject().apply {
val uvmEntries = uvmEntries
val credProps = credProps
val prf = prf
val txAuthSimple = txAuthSimple
if (uvmEntries != null) put("uvm", uvmEntries.toJsonArray())
if (credProps != null) put("credProps", credProps.toJsonObject())
if (prf != null) put("prf", prf.toJsonObject())
if (txAuthSimple != null) put("txAuthSimple", txAuthSimple)
}
fun UvmEntries.toJsonArray() = JSONArray().apply {
val uvmEntryList = uvmEntryList
if (uvmEntryList != null) {
for (uvmEntry in uvmEntryList) {
put(uvmEntry.toJsonArray())
}
}
}
fun UvmEntry.toJsonArray() = JSONArray().apply {
put(userVerificationMethod)
put(keyProtectionType)
put(matcherProtectionType)
}
fun AuthenticationExtensionsCredPropsOutputs.toJsonObject() = JSONObject().apply {
put("rk", isDiscoverableCredential)
}
fun AuthenticationExtensionsPrfOutputs.toJsonObject() = JSONObject().apply {
val first = first
val second = second
if (isEnabled) put("enabled", true)
if (first != null) put("first", first.encodeBase64Url())
if (second != null) put("second", second.encodeBase64Url())
}
fun JSONObject.parsePublicKeyCredentialRequestOptions(): PublicKeyCredentialRequestOptions {
val builder = PublicKeyCredentialRequestOptions.Builder()
builder.setChallenge(getString("challenge").decodeBase64Url())
if (has("timeout")) {
builder.setTimeoutSeconds(getDouble("timeout") / 1000.0)
} else if (has("timeoutSeconds")) {
builder.setTimeoutSeconds(getDouble("timeoutSeconds"))
}
builder.setRpId(getString("rpId"))
val allowCredentials = when {
has("allowList") -> getJSONArray("allowList")
has("allowCredentials") -> getJSONArray("allowCredentials")
else -> null
}
if (allowCredentials != null) {
val allowList = arrayListOf<PublicKeyCredentialDescriptor>()
for (i in 0..<allowCredentials.length()) {
allowList.add(allowCredentials.getJSONObject(i).parsePublicKeyCredentialDescriptor())
}
builder.setAllowList(allowList)
}
if (has("requestId")) {
builder.setRequestId(getInt("requestId"))
}
if (has("tokenBinding")) {
builder.setTokenBinding(getJSONObject("tokenBinding").parseTokenBinding())
}
if (has("userVerification")) {
builder.setRequireUserVerification(UserVerificationRequirement.fromString(getString("userVerification")))
}
if (has("authenticationExtensions")) {
builder.setAuthenticationExtensions(getJSONObject("authenticationExtensions").parseAuthenticationExtensions())
} else if (has("extensions")) {
builder.setAuthenticationExtensions(getJSONObject("extensions").parseAuthenticationExtensions())
}
if (has("longRequestId")) {
builder.setLongRequestId(getLong("longRequestId"))
}
return builder.build()
}
fun JSONObject.parseTokenBinding() = when (TokenBindingStatus.fromString(getString("status"))) {
TokenBindingStatus.SUPPORTED -> TokenBinding.SUPPORTED
TokenBindingStatus.NOT_SUPPORTED -> TokenBinding.NOT_SUPPORTED
TokenBindingStatus.PRESENT -> TokenBinding(getString("id"))
}
fun JSONObject.parseAuthenticationExtensions(): AuthenticationExtensions {
val builder = AuthenticationExtensions.Builder()
if (has("fidoAppIdExtension")) builder.setFido2Extension(FidoAppIdExtension(getJSONObject("fidoAppIdExtension").getString("appId")))
if (has("appid")) builder.setFido2Extension(FidoAppIdExtension(getString("appId")))
// TODO: Add support for other extensions
return builder.build()
}
fun JSONObject.parsePublicKeyCredentialDescriptor() = PublicKeyCredentialDescriptor(
getString("type"),
getString("id").decodeBase64Url(),
optJSONArray("transports")?.let { Transport.parseTransports(it) }
)
fun JSONObject.parsePublicKeyCredentialCreationOptions(): PublicKeyCredentialCreationOptions {
val builder = PublicKeyCredentialCreationOptions.Builder()
builder.setRp(getJSONObject("rp").parsePublicKeyCredentialRpEntity())
builder.setUser(getJSONObject("user").parsePublicKeyCredentialUserEntity())
builder.setChallenge(getString("challenge").decodeBase64Url())
val pubKeyCredParams = getJSONArray("pubKeyCredParams")
val parameters = arrayListOf<PublicKeyCredentialParameters>()
for (i in 0..<pubKeyCredParams.length()) {
parameters.add(pubKeyCredParams.getJSONObject(i).parsePublicKeyCredentialParameters())
}
builder.setParameters(parameters)
if (has("timeout")) {
builder.setTimeoutSeconds(getDouble("timeout") / 1000.0)
}
if (has("excludeCredentials")) {
val excludeCredentials = getJSONArray("excludeCredentials")
val excludeList = arrayListOf<PublicKeyCredentialDescriptor>()
for (i in 0..<excludeCredentials.length()) {
excludeList.add(excludeCredentials.getJSONObject(i).parsePublicKeyCredentialDescriptor())
}
builder.setExcludeList(excludeList)
}
if (has("authenticatorSelection")) {
builder.setAuthenticatorSelection(getJSONObject("authenticatorSelection").parseAuthenticatorSelectionCriteria())
}
if (has("extensions")) {
builder.setAuthenticationExtensions(getJSONObject("extensions").parseAuthenticationExtensions())
}
if (has("attestation")) {
try {
builder.setAttestationConveyancePreference(AttestationConveyancePreference.fromString(getString("attestation")))
} catch (e: Exception) {
builder.setAttestationConveyancePreference(AttestationConveyancePreference.NONE)
}
}
return builder.build()
}
fun JSONObject.parsePublicKeyCredentialRpEntity() = PublicKeyCredentialRpEntity(
getString("id"),
getString("name"),
if (has("icon")) optString("icon") else null
)
fun JSONObject.parsePublicKeyCredentialUserEntity() = PublicKeyCredentialUserEntity(
getString("id").decodeBase64Url(),
getString("name"),
if (has("icon")) optString("icon") else null,
optString("displayName")
)
fun JSONObject.parsePublicKeyCredentialParameters() = PublicKeyCredentialParameters(
getString("type"),
getInt("alg")
)
fun JSONObject.parseAuthenticatorSelectionCriteria() = AuthenticatorSelectionCriteria.Builder()
.setAttachment(if (has("authenticatorAttachment")) optString("authenticatorAttachment").let { Attachment.fromString(it) } else null)
.setRequireResidentKey(if (has("requireResidentKey")) optBoolean("requireResidentKey") else null)
.setRequireUserVerification(if (has("userVerification")) optString("userVerification").let { UserVerificationRequirement.fromString(it) } else null)
.setResidentKeyRequirement(if (has("residentKey")) optString("residentKey").let { ResidentKeyRequirement.fromString(it) } else null)
.build()

View file

@ -0,0 +1,187 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.credentials.provider
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.CredentialEntry
import androidx.credentials.provider.CredentialProviderService
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.credentials.PasswordCredential.Companion.TYPE_PASSWORD_CREDENTIAL
import androidx.credentials.PublicKeyCredential.Companion.TYPE_PUBLIC_KEY_CREDENTIAL
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.CustomCredentialEntry
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
import com.squareup.wire.Instant
import org.json.JSONObject
import org.microg.gms.fido.core.CredentialUserInfo
import org.microg.gms.fido.core.Database
import org.microg.gms.fido.core.R
import org.microg.gms.fido.core.transport.Transport
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_CREDENTIAL_ID
private const val TAG = "PasswordAndPasskey"
/**
* Password and Passkey Credential Provider Service
* Handles both password and passkey (FIDO2/WebAuthn) credentials
*/
@RequiresApi(34)
open class PasswordAndPasskeyService : CredentialProviderService() {
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>
) {
runCatching {
val credentialEntries = request.beginGetCredentialOptions
.flatMap { option ->
when (option.type) {
TYPE_PUBLIC_KEY_CREDENTIAL -> handlePublicKeyCredentialRequest(option as BeginGetPublicKeyCredentialOption, request)
TYPE_PASSWORD_CREDENTIAL -> emptyList() // TODO: handle password credential request
else -> emptyList<CredentialEntry>().also {
Log.d(TAG, "Unsupported credential type: ${option.type}")
}
}
}
callback.onResult(BeginGetCredentialResponse.Builder()
.setCredentialEntries(credentialEntries)
.build()
.also { Log.d(TAG, "Returning ${credentialEntries.size} credential entries") })
}.onFailure { e ->
Log.e(TAG, "Error in onBeginGetCredential", e)
callback.onError(GetCredentialUnknownException(e.message))
}
}
private fun handlePublicKeyCredentialRequest(
option: BeginGetPublicKeyCredentialOption,
request: BeginGetCredentialRequest
): List<CredentialEntry> = runCatching {
val options = JSONObject(option.requestJson).parsePublicKeyCredentialRequestOptions()
var entries = Database(this).getKnownRegistrationInfo(options.rpId)
.filter { it.transport == Transport.SCREEN_LOCK } // TODO: Also show known remote credentials?
.filter { info -> options.allowList.isNullOrEmpty() || options.allowList!!.any { it.id.encodeBase64Url() == info.credential } }
.also { Log.d(TAG, "Found ${it.size} credentials for rpId: ${options.rpId}") }
.mapNotNull { credentialInfo ->
createSignInCredentialEntry(credentialInfo, option, !options.allowList.isNullOrEmpty())
}
if (!RemoteService.hasPermissionForRemoteEntry(this)) {
entries += CustomCredentialEntry(
context = this,
title = getString(R.string.fido_transport_selection_hybrid),
pendingIntent = getPendingIntent(0),
beginGetCredentialOption = option,
subtitle = getString(com.google.android.gms.R.string.credentials_service_remote_custom_subtitle),
lastUsedTime = Instant.ofEpochMilli(0)
)
}
entries
}.getOrElse { e ->
Log.e(TAG, "Error handling public key credential request", e)
emptyList()
}
private fun createSignInCredentialEntry(
credentialInfo: CredentialUserInfo,
option: BeginGetPublicKeyCredentialOption,
isAutoSelectAllowed: Boolean = false
): CredentialEntry? = runCatching {
val user = PublicKeyCredentialUserEntity.parseJson(credentialInfo.userJson)
val pendingIntent = getPendingIntent(credentialInfo.credential.hashCode(), credentialInfo.credential)
PublicKeyCredentialEntry(
context = this,
username = user.name,
pendingIntent = pendingIntent,
beginGetPublicKeyCredentialOption = option,
displayName = user.displayName,
lastUsedTime = Instant.ofEpochMilli(credentialInfo.timestamp),
isAutoSelectAllowed = isAutoSelectAllowed
)
}.getOrElse { e ->
Log.e(TAG, "Error parsing credential user info", e)
null
}
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
) {
runCatching {
when (request.type) {
TYPE_PUBLIC_KEY_CREDENTIAL -> handleCreatePublicKeyCredential(request as BeginCreatePublicKeyCredentialRequest, callback)
TYPE_PASSWORD_CREDENTIAL -> error("Password credential creation not supported")
else -> callback.onError(CreateCredentialUnknownException())
.also { Log.w(TAG, "Unsupported credential type: ${request.type}") }
}
}.onFailure { e ->
Log.e(TAG, "Error in onBeginCreateCredential", e)
callback.onError(CreateCredentialUnknownException(e.message))
}
}
@SuppressLint("MutableImplicitPendingIntent")
private fun handleCreatePublicKeyCredential(
request: BeginCreatePublicKeyCredentialRequest,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
) = runCatching {
val options = JSONObject(request.requestJson).parsePublicKeyCredentialCreationOptions()
val pendingIntent = getPendingIntent(request.requestJson.hashCode())
callback.onResult(BeginCreateCredentialResponse.Builder()
.addCreateEntry(CreateEntry(options.user.name, pendingIntent))
.build()
.also { Log.d(TAG, "Returning create credential response for passkey") })
}.onFailure { e ->
Log.e(TAG, "Error creating public key credential entry", e)
callback.onError(CreateCredentialUnknownException(e.message))
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>
) {
callback.onResult(null)
}
private fun getPendingIntent(requestCode: Int, credentialIdString: String? = null) = PendingIntent.getActivity(
this,
requestCode,
Intent(this, PublicKeyProxyActivity::class.java).apply {
if (credentialIdString != null) putExtra(KEY_CREDENTIAL_ID, credentialIdString)
},
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}

View file

@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.credentials.provider
import android.content.Intent
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.GetCredentialResponse
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.provider.ProviderCreateCredentialRequest
import androidx.credentials.provider.ProviderGetCredentialRequest
import com.google.android.gms.fido.Fido.FIDO2_KEY_CREDENTIAL_EXTRA
import com.google.android.gms.fido.fido2.api.common.*
import org.json.JSONObject
import org.microg.gms.common.GmsService
import org.microg.gms.fido.core.ui.ACTION_FIDO_AUTHENTICATE
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_CALLER
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_CREDENTIAL_ID
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_OPTIONS
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_SERVICE
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_SOURCE
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.KEY_TYPE
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.SOURCE_APP
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.SOURCE_BROWSER
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.TYPE_REGISTER
import org.microg.gms.fido.core.ui.AuthenticatorActivity.Companion.TYPE_SIGN
private const val TAG = "PublicKeyProxyActivity"
private const val REQUEST_CODE_FIDO = 1001
fun String?.isHttpsUrl(): Boolean = this?.startsWith("https://") == true
@RequiresApi(34)
class PublicKeyProxyActivity : CredentialProviderActivity() {
override fun onProviderGetCredentialRequest(request: ProviderGetCredentialRequest) {
val option = request.credentialOptions.firstOrNull() as? GetPublicKeyCredentialOption ?: throw IllegalArgumentException()
Log.d(TAG, "get request json: ${option.requestJson}")
val isBrowserRequest = request.callingAppInfo.origin.isHttpsUrl()
val options = JSONObject(option.requestJson).parsePublicKeyCredentialRequestOptions()
val credentialIdString = intent.getStringExtra(KEY_CREDENTIAL_ID)
val (optionsBytes, source) = buildRequestOptions(options, isBrowserRequest, request.callingAppInfo.origin, option.clientDataHash)
val fidoIntent = createFidoIntent(source, optionsBytes, request.callingAppInfo.packageName, TYPE_SIGN, credentialIdString)
startActivityForResult(fidoIntent, REQUEST_CODE_FIDO)
}
fun buildRequestOptions(
baseOptions: PublicKeyCredentialRequestOptions, isBrowserRequest: Boolean, origin: String?, clientDataHash: ByteArray?
): Pair<ByteArray, String> = if (isBrowserRequest && origin != null) {
BrowserPublicKeyCredentialRequestOptions.Builder().setPublicKeyCredentialRequestOptions(baseOptions).setOrigin(origin.toUri()).apply { clientDataHash?.let(::setClientDataHash) }.build()
.serializeToBytes() to SOURCE_BROWSER
} else {
baseOptions.serializeToBytes() to SOURCE_APP
}
override fun onProviderCreateCredentialRequest(request: ProviderCreateCredentialRequest) {
val callingPackage = request.callingAppInfo.packageName
val origin = request.callingAppInfo.origin
val isBrowserRequest = origin.isHttpsUrl()
val publicKeyRequest = request.callingRequest as CreatePublicKeyCredentialRequest
Log.d(TAG, "Creating passkey for: $callingPackage, browser=$isBrowserRequest")
val options = JSONObject(publicKeyRequest.requestJson).parsePublicKeyCredentialCreationOptions()
Log.d(TAG, "handlePasskeyCreate: options: $options")
val (optionsBytes, source) = buildCreationOptions(options, isBrowserRequest, origin, publicKeyRequest.clientDataHash)
val fidoIntent = createFidoIntent(source, optionsBytes, callingPackage, TYPE_REGISTER)
startActivityForResult(fidoIntent, REQUEST_CODE_FIDO)
Log.d(TAG, "Launched FIDO authenticator by PasskeyCreate")
}
fun buildCreationOptions(
baseOptions: PublicKeyCredentialCreationOptions, isBrowserRequest: Boolean, origin: String?, clientDataHash: ByteArray?
): Pair<ByteArray, String> = if (isBrowserRequest && origin != null) {
BrowserPublicKeyCredentialCreationOptions.Builder().setPublicKeyCredentialCreationOptions(baseOptions).setOrigin(origin.toUri()).apply { clientDataHash?.let(::setClientDataHash) }.build()
.serializeToBytes() to SOURCE_BROWSER
} else {
baseOptions.serializeToBytes() to SOURCE_APP
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != REQUEST_CODE_FIDO) return
if (resultCode != RESULT_OK || data == null) {
Log.w(TAG, "FIDO activity canceled or failed")
return finishWithException("Sign in canceled")
}
runCatching {
val credentialBytes = data.getByteArrayExtra(FIDO2_KEY_CREDENTIAL_EXTRA)
?: return@runCatching finishWithException("No credential data in FIDO result")
val publicKeyCredential = PublicKeyCredential.deserializeFromBytes(credentialBytes)
(publicKeyCredential.response as? AuthenticatorErrorResponse)?.let { error ->
Log.e(TAG, "FIDO error: ${error.errorMessage}")
return@runCatching finishWithException(error.errorMessage)
}
handleFidoSuccess(publicKeyCredential)
}.onFailure { e ->
Log.e(TAG, "Error processing FIDO result", e)
finishWithException(e.localizedMessage)
}
}
fun createFidoIntent(
source: String, optionsBytes: ByteArray, callingPackage: String, type: String, credentialIdString: String? = null
): Intent = Intent(ACTION_FIDO_AUTHENTICATE).apply {
`package` = packageName
putExtra(KEY_SERVICE, GmsService.FIDO2_API.SERVICE_ID)
putExtra(KEY_SOURCE, source)
putExtra(KEY_TYPE, type)
putExtra(KEY_OPTIONS, optionsBytes)
putExtra(KEY_CALLER, callingPackage)
credentialIdString?.let { putExtra(KEY_CREDENTIAL_ID, it) }
}
private fun handleFidoSuccess(publicKeyCredential: PublicKeyCredential) = runCatching {
when (val response = publicKeyCredential.response) {
is AuthenticatorAttestationResponse -> {
val responseJson = publicKeyCredential.toJson()
Log.d(TAG, "Passkey created successfully: $responseJson")
finishWithSuccess(CreatePublicKeyCredentialResponse(responseJson))
}
is AuthenticatorAssertionResponse -> {
val responseJson = publicKeyCredential.toJson()
Log.d(TAG, "Passkey authentication successful: $responseJson")
finishWithSuccess(GetCredentialResponse(androidx.credentials.PublicKeyCredential(responseJson)))
}
else -> {
Log.e(TAG, "Unknown response type: ${response.javaClass.simpleName}")
finishWithException()
}
}
}.onFailure { e ->
Log.e(TAG, "Error handling FIDO success", e)
finishWithException(e.localizedMessage)
}
}

View file

@ -0,0 +1,133 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.credentials.provider
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.CancellationSignal
import android.os.OutcomeReceiver
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.credentials.PublicKeyCredential.Companion.TYPE_PUBLIC_KEY_CREDENTIAL
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.exceptions.NoCredentialException
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.CustomCredentialEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import androidx.credentials.provider.RemoteEntry
import com.google.android.gms.fido.fido2.api.common.Attachment
import com.squareup.wire.Instant
import org.json.JSONObject
private const val TAG = "RemoteCredentialService"
/**
* RemoteService - Provides cross-device passkey functionality
*
* RemoteChimeraService corresponding to GMS, realizing cross-device authentication function:
* - Connect other devices via QR code or Bluetooth
* - Authenticate with a passkey on another device
* - Create passkeys on other devices
*/
@RequiresApi(34)
open class RemoteService : CredentialProviderService() {
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>
) {
runCatching {
val option = request.beginGetCredentialOptions.firstOrNull { it.type == TYPE_PUBLIC_KEY_CREDENTIAL }
as? BeginGetPublicKeyCredentialOption?
?: return callback.onError(NoCredentialException())
// TODO: Don't offer when allowedCredentials set and all of them local
val responseBuilder = BeginGetCredentialResponse.Builder()
val pendingIntent = createPendingIntent()
responseBuilder.setRemoteEntry(RemoteEntry(pendingIntent))
callback.onResult(responseBuilder.build())
}.onFailure { e ->
Log.e(TAG, "Error in onBeginGetCredential", e)
callback.onError(GetCredentialUnknownException(e.message))
}
}
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>
) {
runCatching {
if (request.type != TYPE_PUBLIC_KEY_CREDENTIAL) {
return callback.onError(CreateCredentialNoCreateOptionException())
.also { Log.d(TAG, "Not a PublicKeyCredential create request") }
}
val publicKeyRequest = request as BeginCreatePublicKeyCredentialRequest
val options = JSONObject(publicKeyRequest.requestJson).parsePublicKeyCredentialCreationOptions()
if (options.authenticatorSelection?.attachment == Attachment.PLATFORM) {
return callback.onError(CreateCredentialNoCreateOptionException())
.also { Log.d(TAG, "Platform attachment required, remote not supported") }
}
val responseBuilder = BeginCreateCredentialResponse.Builder()
val pendingIntent = createPendingIntent()
responseBuilder.setRemoteEntry(RemoteEntry(pendingIntent))
callback.onResult(responseBuilder.build())
}.onFailure { e ->
Log.e(TAG, "Error in onBeginCreateCredential", e)
callback.onError(CreateCredentialUnknownException(e.message))
}
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>
) {
Log.d(TAG, "onClearCredentialState: No-op for remote service")
callback.onResult(null)
}
private fun createPendingIntent(): PendingIntent {
return PendingIntent.getActivity(
this,
TAG.hashCode(),
Intent(this, PublicKeyProxyActivity::class.java),
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
companion object {
fun hasPermissionForRemoteEntry(context: Context): Boolean {
return context.checkSelfPermission("android.permission.PROVIDE_DEFAULT_ENABLED_CREDENTIAL_SERVICE") == PackageManager.PERMISSION_GRANTED
}
}
}

View file

@ -0,0 +1,113 @@
/*
* SPDX-FileCopyrightText: 2026 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.auth.credentials.provider
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.ProviderCreateCredentialRequest
import androidx.credentials.provider.ProviderGetCredentialRequest
import com.google.android.gms.auth.api.identity.GetSignInIntentRequest
import com.google.android.gms.auth.api.identity.SignInCredential
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer
import org.microg.gms.auth.AuthConstants
import org.microg.gms.auth.signin.ACTION_ASSISTED_SIGN_IN
import org.microg.gms.auth.signin.CLIENT_PACKAGE_NAME
import org.microg.gms.auth.signin.GET_SIGN_IN_INTENT_REQUEST
import org.microg.gms.auth.signin.GOOGLE_SIGN_IN_OPTIONS
private const val TAG = "SignInProxyActivity"
private const val REQUEST_CODE_SIGN_IN = 100
@RequiresApi(34)
class SignInProxyActivity : CredentialProviderActivity() {
override fun onProviderGetCredentialRequest(request: ProviderGetCredentialRequest) {
val bundle = Bundle().apply {
val signInRequest = GetSignInIntentRequest.builder()
.setServerClientId(intent.getStringExtra(GOOGLE_ID_SIWG_SERVER_CLIENT_ID) ?: "")
.apply {
intent.getStringExtra(GOOGLE_ID_SIWG_NONCE)?.let { setNonce(it) }
}
.build()
val googleSignInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestIdToken(intent.getStringExtra(GOOGLE_ID_SIWG_SERVER_CLIENT_ID) ?: "")
.apply { intent.getStringExtra(GOOGLE_ID_SIWG_ACCOUNT_NAME)?.let { setAccountName(it) } }
.build()
putByteArray(GET_SIGN_IN_INTENT_REQUEST, SafeParcelableSerializer.serializeToBytes(signInRequest))
putByteArray(GOOGLE_SIGN_IN_OPTIONS, SafeParcelableSerializer.serializeToBytes(googleSignInOptions))
putString(CLIENT_PACKAGE_NAME, intent.getStringExtra(GOOGLE_ID_SIWG_CALLER_PACKAGE))
}
startActivityForResult(
Intent(ACTION_ASSISTED_SIGN_IN).apply {
`package` = packageName
putExtras(bundle)
},
REQUEST_CODE_SIGN_IN
)
}
override fun onProviderCreateCredentialRequest(request: ProviderCreateCredentialRequest) {
finishWithException("Unsupported create credential request")
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != REQUEST_CODE_SIGN_IN) return
if (resultCode != RESULT_OK || data == null) {
Log.w(TAG, "SignIn activity canceled or failed")
return finishWithException("Sign in canceled", getExceptionCreator = ::GetCredentialCancellationException)
}
Log.d(TAG, "onActivityResult - requestCode: $requestCode, resultCode: $resultCode")
handleSignInSuccess(data)
}
private fun handleSignInSuccess(data: Intent?) = runCatching {
Log.d(TAG, "handleSignInSuccess")
val credentialBytes = data?.getByteArrayExtra(AuthConstants.SIGN_IN_CREDENTIAL)
?: run {
Log.e(TAG, "No credential data in result")
return@runCatching finishWithException("No credential data")
}
val signInCredential = SafeParcelableSerializer.deserializeFromBytes(
credentialBytes,
SignInCredential.CREATOR
)
Log.d(TAG, "Got SignInCredential: email=${signInCredential.id}, googleIdToken=${signInCredential.googleIdToken?.take(50)}...")
val credentialData = Bundle().apply {
putString(GOOGLE_ID_BUNDLE_KEY_ID, signInCredential.id)
putString(GOOGLE_ID_BUNDLE_KEY_ID_TOKEN, signInCredential.googleIdToken)
putString(GOOGLE_ID_BUNDLE_KEY_DISPLAY_NAME, signInCredential.displayName)
putString(GOOGLE_ID_BUNDLE_KEY_PROFILE_PICTURE_URI, signInCredential.profilePictureUri?.toString())
}
val credential = CustomCredential(TYPE_GOOGLE_ID_TOKEN_CREDENTIAL, credentialData)
val response = GetCredentialResponse(credential)
val resultIntent = Intent()
PendingIntentHandler.setGetCredentialResponse(resultIntent, response)
setResult(RESULT_OK, resultIntent)
Log.d(TAG, "Returning credential to Credential Manager")
finish()
}.onFailure { e ->
Log.e(TAG, "Error processing sign-in result", e)
finishWithException(e.message)
}
}

View file

@ -124,7 +124,7 @@ class AssistedSignInActivity : AppCompatActivity() {
googleSignInAccount.displayName,
googleSignInAccount.givenName,
googleSignInAccount.familyName,
null,
googleSignInAccount.photoUrl,
null,
googleSignInAccount.idToken,
null,

View file

@ -342,6 +342,8 @@ Please set up a password, PIN, or pattern lock screen."</string>
<string name="credentials_assisted_choose_account_error_tips">Account abnormality</string>
<string name="credentials_assisted_signin_button_text_long">Sign in with Google</string>
<string name="credentials_assisted_signin_add_account">Add Account</string>
<string name="credentials_service_sign_in_with_google_label">Sign in with Google</string>
<string name="credentials_service_remote_custom_subtitle">Security key, smartphone or tablet</string>
<string name="limited_services_dialog_information">You are using the microG Limited Services. Unlike the usual microG Services, this flavor only works with apps using microG libraries, not those on Google Play. This means that most applications will ignore these services.</string>
<string name="limited_services_dialog_information_ack">I understand</string>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<credential-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:settingsSubtitle="@string/credentials_service_sign_in_with_google_label"
android:settingsActivity="org.microg.gms.ui.SettingsActivity"
tools:targetApi="34">
<capabilities>
<capability name="com.google.android.libraries.identity.googleid.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL"/>
</capabilities>
</credential-provider>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<credential-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:targetApi="34">
<capabilities>
<capability name="androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL"/>
</capabilities>
</credential-provider>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<credential-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:targetApi="34">
<capabilities>
<capability name="androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL"/>
</capabilities>
</credential-provider>

View file

@ -36,7 +36,7 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
fun getKnownRegistrationInfo(rpId: String) = readableDatabase.use {
val cursor = it.query(
TABLE_KNOWN_REGISTRATIONS,
arrayOf(COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT),
arrayOf(COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT, COLUMN_TIMESTAMP),
"$COLUMN_RP_ID=?",
arrayOf(rpId),
null,
@ -49,8 +49,9 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
val credentialId = c.getString(0)
val userJson = c.getStringOrNull(1) ?: continue
val transport = c.getStringOrNull(2) ?: continue
val timestamp = c.getLongOrNull(3) ?: 0
Log.d(TAG, "getKnownRegistrationInfo: credential: $credentialId user: $userJson transport: $transport")
result.add(CredentialUserInfo(credentialId, userJson, Transport.valueOf(transport)))
result.add(CredentialUserInfo(credentialId, userJson, Transport.valueOf(transport), timestamp))
}
}
result
@ -75,11 +76,7 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
}
}
val updated = if (userJson == null) {
it.update(TABLE_KNOWN_REGISTRATIONS, values, "$COLUMN_RP_ID = ? AND $COLUMN_CREDENTIAL_ID = ?", arrayOf(rpId, credentialId))
} else {
it.update(TABLE_KNOWN_REGISTRATIONS, values, "$COLUMN_RP_ID = ? AND $COLUMN_REGISTER_USER = ?", arrayOf(rpId, userJson))
}
val updated = it.update(TABLE_KNOWN_REGISTRATIONS, values, "$COLUMN_RP_ID = ? AND $COLUMN_CREDENTIAL_ID = ?", arrayOf(rpId, credentialId))
if (updated == 0) {
val insertValues = ContentValues().apply {
@ -138,4 +135,4 @@ fun SQLiteDatabase.count(table: String, selection: String? = null, vararg select
} finally {
it.close()
}
}
}

View file

@ -31,7 +31,7 @@ class RequestHandlingException(val errorCode: ErrorCode, message: String? = null
class MissingPinException(message: String? = null): Exception(message)
class WrongPinException(message: String? = null): Exception(message)
data class CredentialUserInfo(val credential: String, val userJson: String, val transport: Transport)
data class CredentialUserInfo(val credential: String, val userJson: String, val transport: Transport, val timestamp: Long)
enum class RequestOptionsType { REGISTER, SIGN }
val RequestOptions.registerOptions: PublicKeyCredentialCreationOptions

View file

@ -64,13 +64,6 @@ fun CBORObject.decodeAsPublicKeyCredentialUserEntity() = PublicKeyCredentialUser
get("displayName")?.AsString() ?: "".also { Log.w(TAG, "displayName was not present") }
)
fun CBORObject.decodeAsCoseKey() = CoseKey(
getAlgorithm(get(CoseKey.ALG).AsInt32Value()),
get(CoseKey.X).GetByteString(),
get(CoseKey.Y).GetByteString(),
get(CoseKey.CRV).AsInt32Value()
)
fun getAlgorithm(algorithmInt: Int): Algorithm {
return when (algorithmInt) {
-65535 -> RSAAlgorithm.RS1

View file

@ -6,9 +6,18 @@
package org.microg.gms.fido.core.protocol
import com.google.android.gms.fido.fido2.api.common.Algorithm
import com.google.android.gms.fido.fido2.api.common.EC2Algorithm
import com.google.android.gms.fido.fido2.api.common.RSAAlgorithm
import com.upokecenter.cbor.CBOREncodeOptions
import com.upokecenter.cbor.CBORObject
import java.math.BigInteger
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.PublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.ECPoint
import java.security.spec.ECPublicKeySpec
class CoseKey(
val algorithm: Algorithm,
@ -29,6 +38,30 @@ class CoseKey(
set(Y, y.encodeAsCbor())
}
fun asCryptoKey(): PublicKey? {
return when(algorithm) {
is EC2Algorithm -> {
val curveName = when (curveId) {
1 -> "secp256r1"
2 -> "secp384r1"
3 -> "secp521r1"
4 -> "x25519"
5 -> "x448"
6 -> "Ed25519"
7 -> "Ed448"
else -> return null
}
val parameters = AlgorithmParameters.getInstance("EC")
parameters.init(ECGenParameterSpec(curveName))
val parameterSpec = parameters.getParameterSpec(ECParameterSpec::class.java)
val keySpec = ECPublicKeySpec(ECPoint(BigInteger(1, x), BigInteger(1, y)), parameterSpec)
KeyFactory.getInstance("EC").generatePublic(keySpec)
}
else -> null
}
}
companion object {
const val KTY = 1
const val ALG = 3
@ -36,6 +69,15 @@ class CoseKey(
const val X = -2
const val Y = -3
fun decode(bytes: ByteArray): CoseKey = decodeFromCbor(CBORObject.DecodeFromBytes(bytes))
fun decodeFromCbor(obj: CBORObject): CoseKey = CoseKey(
getAlgorithm(obj.get(CoseKey.ALG).AsInt32Value()),
obj.get(CoseKey.X).GetByteString(),
obj.get(CoseKey.Y).GetByteString(),
obj.get(CoseKey.CRV).AsInt32Value()
)
fun BigInteger.toByteArray(size: Int): ByteArray {
val res = ByteArray(size)
val orig = toByteArray()

View file

@ -2,7 +2,6 @@ package org.microg.gms.fido.core.protocol.msgs
import com.upokecenter.cbor.CBORObject
import org.microg.gms.fido.core.protocol.CoseKey
import org.microg.gms.fido.core.protocol.decodeAsCoseKey
import org.microg.gms.fido.core.protocol.encodeAsCbor
class AuthenticatorClientPINCommand(request: AuthenticatorClientPINRequest) :
@ -67,7 +66,7 @@ class AuthenticatorClientPINResponse(
companion object {
fun decodeFromCbor(obj: CBORObject) = AuthenticatorClientPINResponse(
obj.get(0x01)?.decodeAsCoseKey(),
obj.get(0x01)?.let { CoseKey.decodeFromCbor(it) },
obj.get(0x02)?.GetByteString(),
obj.get(0x03)?.AsInt32Value()
)

View file

@ -22,17 +22,11 @@ import org.microg.gms.fido.core.protocol.CoseKey.Companion.toByteArray
import org.microg.gms.fido.core.protocol.msgs.*
import org.microg.gms.fido.core.transport.nfc.CtapNfcMessageStatusException
import org.microg.gms.fido.core.transport.usb.ctaphid.CtapHidMessageStatusException
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.security.spec.ECParameterSpec
import java.security.spec.ECPoint
import java.security.spec.ECPublicKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.Mac
@ -45,10 +39,16 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor
open val isSupported: Boolean
get() = false
open suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean = false, pin: String? = null, user: PublicKeyCredentialUserEntity? = null): AuthenticatorResponseWithUser<*> =
open suspend fun start(
options: RequestOptions,
callerPackage: String,
pinRequested: Boolean = false,
pin: String? = null,
credentialIdString: String? = null
): AuthenticatorResponseWithUser<*> =
throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR)
open fun shouldBeUsedInstantly(options: RequestOptions): Boolean = false
open fun shouldBeUsedInstantly(options: RequestOptions, credential: String? = null): Boolean = false
fun invokeStatusChanged(status: String, extras: Bundle? = null) =
callback?.onStatusChanged(transport, status, extras)
@ -327,9 +327,6 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor
return null;
}
val x = sharedSecretResponse.keyAgreement.x
val y = sharedSecretResponse.keyAgreement.y
val curveName = when (sharedSecretResponse.keyAgreement.curveId) {
1 -> "secp256r1"
2 -> "secp384r1"
@ -346,11 +343,7 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor
generator.initialize(ECGenParameterSpec(curveName))
val myKeyPair = generator.generateKeyPair()
val parameters = AlgorithmParameters.getInstance("EC")
parameters.init(ECGenParameterSpec(curveName))
val parameterSpec = parameters.getParameterSpec(ECParameterSpec::class.java)
val serverKey = KeyFactory.getInstance("EC")
.generatePublic(ECPublicKeySpec(ECPoint(BigInteger(1, x), BigInteger(1, y)), parameterSpec))
val serverKey = sharedSecretResponse.keyAgreement.asCryptoKey()
val keyAgreement = KeyAgreement.getInstance("ECDH")
keyAgreement.init(myKeyPair.private)
keyAgreement.doPhase(serverKey, true)

View file

@ -16,7 +16,6 @@ import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
import com.google.android.gms.fido.fido2.api.common.RequestOptions
import com.google.android.gms.fido.fido2.api.common.UserVerificationRequirement
import com.upokecenter.cbor.CBORObject
@ -47,7 +46,7 @@ class HybridTransportHandler(private val context: Context, callback: TransportHa
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
override suspend fun start(
options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity?
options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, credentialIdString: String?
): AuthenticatorResponseWithUser<*> {
val staticKey = generateEcKeyPair()
val hybridClientController = HybridClientController(context, staticKey)

View file

@ -20,8 +20,6 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.util.Consumer
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse
import com.google.android.gms.fido.fido2.api.common.AuthenticatorResponse
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
import com.google.android.gms.fido.fido2.api.common.RequestOptions
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@ -105,7 +103,13 @@ class NfcTransportHandler(private val activity: Activity, callback: TransportHan
}
override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity?): AuthenticatorResponseWithUser<*> {
override suspend fun start(
options: RequestOptions,
callerPackage: String,
pinRequested: Boolean,
pin: String?,
credentialIdString: String?
): AuthenticatorResponseWithUser<*> {
val adapter = NfcAdapter.getDefaultAdapter(activity)
val newIntentListener = Consumer<Intent> {
if (it?.action != NfcAdapter.ACTION_TECH_DISCOVERED) return@Consumer

View file

@ -198,18 +198,19 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac
suspend fun sign(
options: RequestOptions,
callerPackage: String,
user: PublicKeyCredentialUserEntity?
credentialIdString: String?
): AuthenticatorResponseWithUser<AuthenticatorAssertionResponse> {
if (options.type != RequestOptionsType.SIGN) throw RequestHandlingException(ErrorCode.INVALID_STATE_ERR)
if (!options.signOptions.allowList.isNullOrEmpty() && user != null) throw RequestHandlingException(ErrorCode.NOT_ALLOWED_ERR)
if (options.signOptions.allowList.isNullOrEmpty() && credentialIdString == null) throw RequestHandlingException(ErrorCode.NOT_ALLOWED_ERR)
val knownRegistrationInfo = database.getKnownRegistrationInfo(options.rpId)
.filter { it.transport == Transport.SCREEN_LOCK }
.associateBy { runCatching { CredentialId.decodeTypeAndDataByBase64(it.credential) }.getOrNull() }
.filterKeys { it != null && it.first == 1.toByte() && store.containsKey(options.rpId, it.second) }
.mapKeys { CredentialId(it.key!!.first, it.key!!.second, options.rpId, store.getPublicKey(options.rpId, it.key!!.second)!!) }
val credential = runCatching { credentialIdString?.let { CredentialId.decodeTypeAndDataByBase64(it) } }.getOrNull()
val candidates = if (options.signOptions.allowList.isNullOrEmpty()) {
knownRegistrationInfo
.filterValues { user == null || PublicKeyCredentialUserEntity.parseJson(it.userJson).id.contentEquals(user.id) }
.filterKeys { credential == null || (credential.first == it.type && credential.second.contentEquals(it.data)) }
} else {
options.signOptions.allowList.orEmpty()
.mapNotNull { runCatching { CredentialId.decodeTypeAndData(it.id) }.getOrNull() }
@ -248,13 +249,19 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac
}
@RequiresApi(24)
override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity?): AuthenticatorResponseWithUser<*> =
override suspend fun start(
options: RequestOptions,
callerPackage: String,
pinRequested: Boolean,
pin: String?,
credentialIdString: String?
): AuthenticatorResponseWithUser<*> =
when (options.type) {
RequestOptionsType.REGISTER -> register(options, callerPackage)
RequestOptionsType.SIGN -> sign(options, callerPackage, user)
RequestOptionsType.SIGN -> sign(options, callerPackage, credentialIdString)
}
override fun shouldBeUsedInstantly(options: RequestOptions): Boolean {
override fun shouldBeUsedInstantly(options: RequestOptions, credential: String?): Boolean {
if (options.type != RequestOptionsType.SIGN) return false
for (descriptor in options.signOptions.allowList.orEmpty()) {
try {
@ -266,6 +273,17 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac
// Ignore
}
}
if (credential != null &&
database.getKnownRegistrationTransport(options.rpId, credential) == Transport.SCREEN_LOCK) {
try {
val (type, data) = CredentialId.decodeTypeAndDataByBase64(credential)
if (type == 1.toByte() && store.containsKey(options.rpId, data)) {
return true
}
} catch (e: Exception) {
// Ignore
}
}
return false
}

View file

@ -138,7 +138,13 @@ class UsbTransportHandler(private val context: Context, callback: TransportHandl
}
}
override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity?): AuthenticatorResponseWithUser<*> {
override suspend fun start(
options: RequestOptions,
callerPackage: String,
pinRequested: Boolean,
pin: String?,
credentialIdString: String?
): AuthenticatorResponseWithUser<*> {
for (device in context.usbManager?.deviceList?.values.orEmpty()) {
val iface = getCtapHidInterface(device) ?: continue
try {

View file

@ -40,6 +40,7 @@ import org.microg.gms.utils.getFirstSignatureDigest
import org.microg.gms.utils.toBase64
const val TAG = "FidoUi"
const val ACTION_FIDO_AUTHENTICATE = "org.microg.gms.fido.AUTHENTICATE"
class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
val options: RequestOptions?
@ -77,6 +78,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
lateinit var callerPackage: String
lateinit var callerSignature: String
private lateinit var navHostFragment: NavHostFragment
private var preselectedCredentialId: String? = null
private inline fun <reified T : TransportHandler> getTransportHandler(): T? =
transportHandlers.filterIsInstance<T>().firstOrNull { it.isSupported }
@ -103,15 +105,18 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
this.callerPackage = callerPackage
this.callerSignature = packageManager.getFirstSignatureDigest(callerPackage, "SHA-256")?.toBase64()
?: return finishWithError(UNKNOWN_ERR, "Could not determine signature of app")
this.preselectedCredentialId = intent.getStringExtra(KEY_CREDENTIAL_ID)
Log.d(TAG, "onCreate caller=$callerPackage options=$options")
Log.d(TAG, "onCreate caller=$callerPackage options=$options preselectedCredentialId=$preselectedCredentialId")
val requiresPrivilege =
source == SOURCE_BROWSER && !database.isPrivileged(callerPackage, callerSignature)
// Check if we can directly open screen lock handling
if (!requiresPrivilege) {
val instantTransport = transportHandlers.firstOrNull { it.isSupported && it.shouldBeUsedInstantly(options) }
val instantTransport = transportHandlers.firstOrNull {
it.isSupported && it.shouldBeUsedInstantly(options, preselectedCredentialId)
}
if (instantTransport != null && instantTransport.transport in INSTANT_SUPPORTED_TRANSPORTS) {
window.setBackgroundDrawable(ColorDrawable(0))
window.statusBarColor = Color.TRANSPARENT
@ -150,9 +155,11 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
// Check if we can directly open screen lock handling
if (!requiresPrivilege && allowInstant && !noLocalUserForSignInstantBlock) {
val instantTransport = transportHandlers.firstOrNull { it.isSupported && it.shouldBeUsedInstantly(options) }
val instantTransport = transportHandlers.firstOrNull {
it.isSupported && it.shouldBeUsedInstantly(options, preselectedCredentialId)
}
if (instantTransport != null && instantTransport.transport in INSTANT_SUPPORTED_TRANSPORTS) {
startTransportHandling(instantTransport.transport, true)
startTransportHandling(instantTransport.transport, true, credentialIdString = preselectedCredentialId)
return
}
}
@ -168,6 +175,11 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
val knownRegistrationTransports = mutableSetOf<Transport>()
val allowedTransports = mutableSetOf<Transport>()
if (options.type == RequestOptionsType.SIGN) {
if (preselectedCredentialId != null) {
val knownTransport = database.getKnownRegistrationTransport(options.rpId, preselectedCredentialId!!)
if (knownTransport != null && knownTransport in IMPLEMENTED_TRANSPORTS)
knownRegistrationTransports.add(knownTransport)
}
for (descriptor in options.signOptions.allowList.orEmpty()) {
val knownTransport = database.getKnownRegistrationTransport(options.rpId, descriptor.id.toBase64(Base64.URL_SAFE, Base64.NO_WRAP, Base64.NO_PADDING))
if (knownTransport != null && knownTransport in IMPLEMENTED_TRANSPORTS)
@ -269,7 +281,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
.setRawId(rawId ?: ByteArray(0).also { Log.w(TAG, "rawId was null") })
.setId(id ?: "".also { Log.w(TAG, "id was null") })
.setAuthenticatorAttachment(if (transport == SCREEN_LOCK) "platform" else "cross-platform")
.setAuthenticationExtensionsClientOutputs(clientExtResults)
.setAuthenticationExtensionsClientOutputs(if (response is AuthenticatorAttestationResponse) clientExtResults else null)
.build()
finishWithCredential(pkc, user)
@ -300,10 +312,11 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
}
@RequiresApi(24)
fun startTransportHandling(transport: Transport, instant: Boolean = false, pinRequested: Boolean = false, authenticatorPin: String? = null, user: PublicKeyCredentialUserEntity? = null): Job = lifecycleScope.launchWhenResumed {
fun startTransportHandling(transport: Transport, instant: Boolean = false, pinRequested: Boolean = false, authenticatorPin: String? = null, credentialIdString: String? = null): Job = lifecycleScope.launchWhenResumed {
val options = options ?: return@launchWhenResumed
try {
val result = getTransportHandler(transport)!!.start(options, callerPackage, pinRequested, authenticatorPin, user)
val result = getTransportHandler(transport)!!
.start(options, callerPackage, pinRequested, authenticatorPin, credentialIdString)
finishWithSuccessResponse(result.response, transport, result.user)
} catch (e: SecurityException) {
Log.w(TAG, e)
@ -370,6 +383,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
const val KEY_TYPE = "type"
const val KEY_OPTIONS = "options"
const val KEY_USER_JSON = "userInfo"
const val KEY_CREDENTIAL_ID = "credential"
val REQUIRED_EXTRAS = setOf(KEY_SOURCE, KEY_TYPE, KEY_OPTIONS)
const val SOURCE_BROWSER = "browser"

View file

@ -6,19 +6,13 @@
package org.microg.gms.fido.core.ui
import android.annotation.TargetApi
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.fido.fido2.api.common.ErrorCode
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
import com.google.android.gms.fido.fido2.api.common.RequestOptions
import org.microg.gms.fido.core.*
import org.microg.gms.fido.core.transport.Transport
@TargetApi(24)
@ -31,8 +25,8 @@ abstract class AuthenticatorActivityFragment : Fragment() {
val options: RequestOptions?
get() = authenticatorActivity?.options
fun startTransportHandling(transport: Transport, user: PublicKeyCredentialUserEntity? = null) =
authenticatorActivity?.startTransportHandling(transport, pinRequested = pinViewModel.pinRequest, authenticatorPin = pinViewModel.pin, user = user)
fun startTransportHandling(transport: Transport, credentialIdString: String? = null) =
authenticatorActivity?.startTransportHandling(transport, pinRequested = pinViewModel.pinRequest, authenticatorPin = pinViewModel.pin, credentialIdString = credentialIdString)
fun shouldStartTransportInstantly(transport: Transport) = authenticatorActivity?.shouldStartTransportInstantly(transport) == true
abstract override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View?

View file

@ -24,7 +24,6 @@ import org.microg.gms.fido.core.databinding.FidoSignInSelectionFragmentBinding
import org.microg.gms.fido.core.rpId
import org.microg.gms.fido.core.transport.Transport
import androidx.core.view.isGone
import androidx.core.view.isVisible
import org.microg.gms.fido.core.signOptions
class SignInSelectionFragment : AuthenticatorActivityFragment() {
@ -58,8 +57,8 @@ class SignInSelectionFragment : AuthenticatorActivityFragment() {
}
} else {
binding.root.isGone = false
binding.signInKeyRecycler.adapter = SignInKeyAdapter(knownRegistrationInfo) { userJson, transport ->
startTransportHandling(transport, PublicKeyCredentialUserEntity.parseJson(userJson))
binding.signInKeyRecycler.adapter = SignInKeyAdapter(knownRegistrationInfo) { credentialIdString, transport ->
startTransportHandling(transport, credentialIdString)
}
}
}
@ -79,7 +78,7 @@ internal class SignInKeyAdapter(val data: List<CredentialUserInfo>, val onKeyCli
holder.signInKeyName.text = user.displayName
holder.signInKeyEmail.text = user.name
user.icon?.takeIf { it.isNotBlank() }?.let { ImageManager.create(holder.itemView.context).loadImage(it, holder.signInKeyLogo) }
holder.itemView.setOnClickListener { onKeyClick(item.userJson, item.transport) }
holder.itemView.setOnClickListener { onKeyClick(item.credential, item.transport) }
}
override fun getItemCount(): Int {

View file

@ -7,7 +7,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View file

@ -7,7 +7,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View file

@ -7,7 +7,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View file

@ -5,7 +5,7 @@
-->
<vector android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960"
android:tint="?attr/colorControlNormal" xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000"
android:pathData="M120,800L120,688Q120,654 137.5,625.5Q155,597 184,582Q246,551 310,535.5Q374,520 440,520Q460,520 480,521.5Q500,523 520,526Q516,584 541,635.5Q566,687 614,720L614,800L120,800ZM760,920L700,860L700,674Q656,661 628,624.5Q600,588 600,540Q600,482 641,441Q682,400 740,400Q798,400 839,441Q880,482 880,540Q880,585 854.5,620Q829,655 790,670L840,720L780,780L840,840L760,920ZM440,480Q374,480 327,433Q280,386 280,320Q280,254 327,207Q374,160 440,160Q506,160 553,207Q600,254 600,320Q600,386 553,433Q506,480 440,480ZM768.5,548.5Q780,537 780,520Q780,503 768.5,491.5Q757,480 740,480Q723,480 711.5,491.5Q700,503 700,520Q700,537 711.5,548.5Q723,560 740,560Q757,560 768.5,548.5Z"/>
</vector>

View file

@ -7,8 +7,7 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5z"/>

View file

@ -7,7 +7,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View file

@ -197,6 +197,8 @@ public class AuthenticationExtensions extends AbstractSafeParcelable {
private FidoAppIdExtension fidoAppIdExtension;
@Nullable
private UserVerificationMethodExtension userVerificationMethodExtension;
@Nullable
private PrfExtension prfExtension;
/**
* The constructor of {@link AuthenticationExtensions.Builder}.
@ -213,6 +215,12 @@ public class AuthenticationExtensions extends AbstractSafeParcelable {
return this;
}
@Hide
public Builder setPrfExtension(@Nullable PrfExtension prfExtension) {
this.prfExtension = prfExtension;
return this;
}
/**
* Sets the User Verification Method extension, which allows the relying party to ascertain up to three
* authentication methods that were used.

View file

@ -43,18 +43,18 @@ public class AuthenticationExtensionsClientOutputs extends AbstractSafeParcelabl
@Field(value = 4, getterName = "getPrfOutputs")
@Nullable
private AuthenticationExtensionsPrfOutputs prfOutputs;
private AuthenticationExtensionsPrfOutputs prf;
@Field(value = 5, getterName = "getTxAuthSimple")
@Nullable
private String txAuthSimple;
@Constructor
public AuthenticationExtensionsClientOutputs(@Param(1) @Nullable UvmEntries uvmEntries, @Param(2) @Nullable AuthenticationExtensionsDevicePublicKeyOutputs devicePublicKeyOutputs, @Param(3) @Nullable AuthenticationExtensionsCredPropsOutputs credProps, @Param(4) @Nullable AuthenticationExtensionsPrfOutputs prfOutputs, @Param(5) @Nullable String txAuthSimple) {
public AuthenticationExtensionsClientOutputs(@Param(1) @Nullable UvmEntries uvmEntries, @Param(2) @Nullable AuthenticationExtensionsDevicePublicKeyOutputs devicePublicKeyOutputs, @Param(3) @Nullable AuthenticationExtensionsCredPropsOutputs credProps, @Param(4) @Nullable AuthenticationExtensionsPrfOutputs prf, @Param(5) @Nullable String txAuthSimple) {
this.uvmEntries = uvmEntries;
this.devicePublicKeyOutputs = devicePublicKeyOutputs;
this.credProps = credProps;
this.prfOutputs = prfOutputs;
this.prf = prf;
this.txAuthSimple = txAuthSimple;
}
@ -74,8 +74,13 @@ public class AuthenticationExtensionsClientOutputs extends AbstractSafeParcelabl
}
@Nullable
public AuthenticationExtensionsPrfOutputs getPrf() {
return prf;
}
@Deprecated
public AuthenticationExtensionsPrfOutputs getPrfOutputs() {
return prfOutputs;
return prf;
}
@Nullable
@ -108,12 +113,12 @@ public class AuthenticationExtensionsClientOutputs extends AbstractSafeParcelabl
if (!(o instanceof AuthenticationExtensionsClientOutputs)) return false;
AuthenticationExtensionsClientOutputs that = (AuthenticationExtensionsClientOutputs) o;
return (Objects.equals(uvmEntries, that.uvmEntries)) && (Objects.equals(devicePublicKeyOutputs, that.devicePublicKeyOutputs)) && (Objects.equals(credProps, that.credProps)) && (Objects.equals(prfOutputs, that.prfOutputs)) && (Objects.equals(txAuthSimple, that.txAuthSimple));
return (Objects.equals(uvmEntries, that.uvmEntries)) && (Objects.equals(devicePublicKeyOutputs, that.devicePublicKeyOutputs)) && (Objects.equals(credProps, that.credProps)) && (Objects.equals(prf, that.prf)) && (Objects.equals(txAuthSimple, that.txAuthSimple));
}
@Override
public int hashCode() {
return Arrays.hashCode(new Object[]{uvmEntries, devicePublicKeyOutputs, credProps, prfOutputs, txAuthSimple});
return Arrays.hashCode(new Object[]{uvmEntries, devicePublicKeyOutputs, credProps, prf, txAuthSimple});
}
/**

View file

@ -32,13 +32,13 @@ import java.util.Objects;
@SafeParcelable.Class
public class PublicKeyCredential extends AbstractSafeParcelable {
@Field(value = 1, getterName = "getId")
@NonNull
@Nullable
private String id;
@Field(value = 2, getterName = "getType")
@NonNull
private String type;
@Field(value = 3, getterName = "getRawId")
@NonNull
@Nullable
private byte[] rawId;
@Field(value = 4, getter = "$object.getResponse() instanceof $type ? ($type) $object.getResponse() : null")
@Nullable
@ -85,12 +85,12 @@ public class PublicKeyCredential extends AbstractSafeParcelable {
return clientExtensionResults;
}
@NonNull
@Nullable
public String getId() {
return id;
}
@NonNull
@Nullable
public byte[] getRawId() {
return rawId;
}

View file

@ -51,12 +51,12 @@ public class PublicKeyCredentialRequestOptions extends RequestOptions {
@Field(value = 9, getterName = "getAuthenticationExtensions")
@Nullable
private AuthenticationExtensions authenticationExtensions;
@Field(10)
@Field(value = 10, getterName = "getLongRequestId")
@Nullable
Long longRequestId;
private final Long longRequestId;
@Constructor
public PublicKeyCredentialRequestOptions(@Param(2)@NonNull byte[] challenge,@Param(3) @Nullable Double timeoutSeconds, @Param(4)@NonNull String rpId, @Param(5)@Nullable List<PublicKeyCredentialDescriptor> allowList,@Param(6) @Nullable Integer requestId,@Param(7) @Nullable TokenBinding tokenBinding,@Param(8) @Nullable UserVerificationRequirement requireUserVerification, @Param(9)@Nullable AuthenticationExtensions authenticationExtensions) {
public PublicKeyCredentialRequestOptions(@Param(2)@NonNull byte[] challenge,@Param(3) @Nullable Double timeoutSeconds, @Param(4)@NonNull String rpId, @Param(5)@Nullable List<PublicKeyCredentialDescriptor> allowList,@Param(6) @Nullable Integer requestId,@Param(7) @Nullable TokenBinding tokenBinding,@Param(8) @Nullable UserVerificationRequirement requireUserVerification, @Param(9)@Nullable AuthenticationExtensions authenticationExtensions, @Param(10) @Nullable Long longRequestId) {
this.challenge = challenge;
this.timeoutSeconds = timeoutSeconds;
this.rpId = rpId;
@ -65,6 +65,7 @@ public class PublicKeyCredentialRequestOptions extends RequestOptions {
this.tokenBinding = tokenBinding;
this.requireUserVerification = requireUserVerification;
this.authenticationExtensions = authenticationExtensions;
this.longRequestId = longRequestId;
}
@Nullable
@ -90,6 +91,12 @@ public class PublicKeyCredentialRequestOptions extends RequestOptions {
return challenge;
}
@Hide
@Nullable
public Long getLongRequestId() {
return longRequestId;
}
@Override
@Nullable
public Integer getRequestId() {
@ -171,6 +178,8 @@ public class PublicKeyCredentialRequestOptions extends RequestOptions {
private AuthenticationExtensions authenticationExtensions;
@Nullable
private UserVerificationRequirement requireUserVerification;
@Nullable
private Long longRequestId;
/**
* The constructor of {@link PublicKeyCredentialRequestOptions.Builder}.
@ -208,6 +217,13 @@ public class PublicKeyCredentialRequestOptions extends RequestOptions {
return this;
}
@Hide
@NonNull
public Builder setLongRequestId(@Nullable Long longRequestId) {
this.longRequestId = longRequestId;
return this;
}
/**
* Sets the request id in order to link together events into a single session (the span of events between the
* time that the server initiates a single FIDO2 request to the client and receives reply) on a single device.
@ -261,7 +277,7 @@ public class PublicKeyCredentialRequestOptions extends RequestOptions {
*/
@NonNull
public PublicKeyCredentialRequestOptions build() {
return new PublicKeyCredentialRequestOptions(challenge, timeoutSeconds, rpId, allowList, requestId, tokenBinding, requireUserVerification, authenticationExtensions);
return new PublicKeyCredentialRequestOptions(challenge, timeoutSeconds, rpId, allowList, requestId, tokenBinding, requireUserVerification, authenticationExtensions, longRequestId);
}
}