fix: Passkey workflow

This commit is contained in:
J-Jamet
2025-09-17 20:02:55 +02:00
parent 1e7e464e65
commit 0aecc21f43
4 changed files with 592 additions and 419 deletions

View File

@@ -31,168 +31,42 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity
import com.kunzisoft.keepass.activities.GroupActivity
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addTypeMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAppOrigin
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addNodeId
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addSearchInfo
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.checkSecurity
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.getVerifiedGETClientDataResponse
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removeAppOrigin
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveAppOrigin
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveSearchInfo
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps
import com.kunzisoft.keepass.credentialprovider.viewmodel.PasskeyLauncherViewModel
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.model.AppOrigin
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.model.SignatureNotFoundException
import com.kunzisoft.keepass.settings.PreferencesUtil
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
import java.io.IOException
import java.io.InvalidObjectException
import java.util.UUID
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeyLauncherActivity : DatabaseModeActivity() {
private val passkeyLauncherViewModel: PasskeyLauncherViewModel by viewModels()
private var mUsageParameters: PublicKeyCredentialUsageParameters? = null
private var mCreationParameters: PublicKeyCredentialCreationParameters? = null
private var mPasskey: Passkey? = null
private var mBackupEligibility: Boolean = true
private var mBackupState: Boolean = false
private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val intent = it.data
val resultCode = it.resultCode
// Build a new formatted response from the selection response
val responseIntent = Intent()
when (resultCode) {
RESULT_OK -> {
try {
Log.d(TAG, "Passkey selection result")
if (intent == null)
throw IOException("Intent is null")
val passkey = intent.retrievePasskey()
?: throw IOException("Passkey is null")
val appOrigin = intent.retrieveAppOrigin()
?: throw IOException("App origin is null")
intent.removePasskey()
intent.removeAppOrigin()
mUsageParameters?.let { usageParameters ->
// Check verified origin
PendingIntentHandler.setGetCredentialResponse(
responseIntent,
GetCredentialResponse(
buildPasskeyPublicKeyCredential(
requestOptions = usageParameters.publicKeyCredentialRequestOptions,
clientDataResponse = getVerifiedGETClientDataResponse(
usageParameters = usageParameters,
appOrigin = appOrigin
),
passkey = passkey,
backupEligibility = mBackupEligibility,
backupState = mBackupState
)
)
)
} ?: run {
throw IOException("Usage parameters is null")
}
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_OK,
data = responseIntent
)
} catch (e: SignatureNotFoundException) {
passkeyLauncherViewModel.showAppSignatureDialog(e.temptingApp)
} catch (e: Exception) {
Log.e(TAG, "Unable to create selection response for passkey", e)
showError(e)
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
}
RESULT_CANCELED -> {
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
}
// Remove the launcher register
passkeyLauncherViewModel.isResultLauncherRegistered = false
passkeyLauncherViewModel.manageSelectionResult(it)
}
private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher(
lockDatabase = true,
dataTransformation = { intent ->
// Build a new formatted response from the creation response
val responseIntent = Intent()
try {
Log.d(TAG, "Passkey registration result")
val passkey = intent?.retrievePasskey()
intent?.removePasskey()
intent?.removeAppOrigin()
// If registered passkey is the same as the one we want to validate,
if (mPasskey == passkey) {
mCreationParameters?.let {
PendingIntentHandler.setCreateCredentialResponse(
intent = responseIntent,
response = buildCreatePublicKeyCredentialResponse(
publicKeyCredentialCreationParameters = it,
backupEligibility = mBackupEligibility,
backupState = mBackupState
)
)
}
} else {
throw SecurityException("Passkey was modified before registration")
}
} catch (e: Exception) {
Log.e(TAG, "Unable to create registration response for passkey", e)
showError(e)
}
responseIntent
}
)
this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
passkeyLauncherViewModel.manageRegistrationResult(it)
}
override fun applyCustomStyle(): Boolean {
return false
@@ -204,75 +78,95 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBackupEligibility = PreferencesUtil.isPasskeyBackupEligibilityEnable(applicationContext)
mBackupState = PreferencesUtil.isPasskeyBackupStateEnable(applicationContext)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
passkeyLauncherViewModel.uiState.collect { uiState ->
when (uiState) {
is PasskeyLauncherViewModel.UIState.Loading -> {
// Nothing to do
}
is PasskeyLauncherViewModel.UIState.ShowAppPrivilegedDialog -> {
showAppPrivilegedDialog(uiState.temptingApp)
}
is PasskeyLauncherViewModel.UIState.ShowAppSignatureDialog -> {
showAppSignatureDialog( uiState.temptingApp)
}
// Initialize the parameters
passkeyLauncherViewModel.initialize()
// Retrieve the UI
passkeyLauncherViewModel.uiState.collect { uiState ->
when (uiState) {
is PasskeyLauncherViewModel.UIState.Loading -> {
// Nothing to do
}
is PasskeyLauncherViewModel.UIState.ShowAppPrivilegedDialog -> {
showAppPrivilegedDialog(
database = uiState.database,
temptingApp = uiState.temptingApp
)
}
is PasskeyLauncherViewModel.UIState.ShowAppSignatureDialog -> {
showAppSignatureDialog(
database = uiState.database,
temptingApp = uiState.temptingApp
)
}
is PasskeyLauncherViewModel.UIState.SetActivityResult -> {
setActivityResult(
lockDatabase = uiState.lockDatabase,
resultCode = uiState.resultCode,
data = uiState.data
)
}
is PasskeyLauncherViewModel.UIState.ShowError -> {
showError(uiState.error)
}
is PasskeyLauncherViewModel.UIState.LaunchGroupActivityForSelection -> {
GroupActivity.launchForPasskeySelectionResult(
context = this@PasskeyLauncherActivity,
database = uiState.database,
activityResultLauncher = mPasskeySelectionActivityResultLauncher,
searchInfo = null,
autoSearch = false
)
}
is PasskeyLauncherViewModel.UIState.LaunchGroupActivityForRegistration -> {
GroupActivity.launchForRegistration(
context = this@PasskeyLauncherActivity,
activityResultLauncher = mPasskeyRegistrationActivityResultLauncher,
database = uiState.database,
registerInfo = uiState.registerInfo,
typeMode = uiState.typeMode
)
}
is PasskeyLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForSelection -> {
FileDatabaseSelectActivity.launchForPasskeySelectionResult(
activity = this@PasskeyLauncherActivity,
activityResultLauncher = mPasskeySelectionActivityResultLauncher,
searchInfo = uiState.searchInfo,
)
}
is PasskeyLauncherViewModel.UIState.LaunchFileDatabaseSelectActivityForRegistration -> {
FileDatabaseSelectActivity.launchForRegistration(
context = this@PasskeyLauncherActivity,
activityResultLauncher = mPasskeyRegistrationActivityResultLauncher,
registerInfo = uiState.registerInfo,
typeMode = uiState.typeMode
)
}
}
}
}
}
private fun cancelRequest() {
setResult(RESULT_CANCELED)
finish()
}
private fun cancelRequest(e: Throwable) {
Log.e(TAG, "Passkey launch error", e)
showError(e)
cancelRequest()
}
/**
* Launch the main action to manage Passkey
*/
private suspend fun launchPasskeyAction(database: ContextualDatabase?) {
passkeyLauncherViewModel.isResultLauncherRegistered = true
val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo()
val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false)
val nodeId = intent.retrieveNodeId()
checkSecurity(intent, nodeId)
when (mSpecialMode) {
SpecialMode.SELECTION -> {
launchSelection(database, nodeId, searchInfo, appOrigin)
}
SpecialMode.REGISTRATION -> {
// TODO Registration in predefined group
// launchRegistration(database, nodeId, mSearchInfo)
launchRegistration(database, null, searchInfo)
}
else -> {
throw InvalidObjectException("Passkey launch mode not supported")
}
}
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
super.onDatabaseRetrieved(database)
passkeyLauncherViewModel.onDatabaseRetrieved(intent, mSpecialMode, database)
}
/**
* Display a dialog that asks the user to add an app to the list of privileged apps.
*/
private fun showAppPrivilegedDialog(temptingApp: AndroidPrivilegedApp) {
private fun showAppPrivilegedDialog(
database: ContextualDatabase,
temptingApp: AndroidPrivilegedApp
) {
Log.w(javaClass.simpleName, "No privileged apps file found")
AlertDialog.Builder(this@PasskeyLauncherActivity).apply {
setTitle(getString(R.string.passkeys_privileged_apps_ask_title))
setMessage(StringBuilder()
.append(
getString(
R.string.passkeys_privileged_apps_ask_message,
temptingApp.toString()
R.string.passkeys_privileged_apps_ask_message,
temptingApp.toString()
)
)
.append("\n\n")
@@ -280,21 +174,18 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
.toString()
)
setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(CoroutineExceptionHandler { _, e ->
cancelRequest(e)
}) {
saveCustomPrivilegedApps(
context = application,
privilegedApps = listOf(temptingApp)
)
launchPasskeyAction(mDatabase)
}
passkeyLauncherViewModel.saveCustomPrivilegedApp(
intent = intent,
specialMode = mSpecialMode,
database = database,
temptingApp = temptingApp
)
}
setNegativeButton(android.R.string.cancel) { _, _ ->
cancelRequest()
passkeyLauncherViewModel.cancelResult()
}
setOnCancelListener {
cancelRequest()
passkeyLauncherViewModel.cancelResult()
}
}.create().show()
}
@@ -302,14 +193,17 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
/**
* Display a dialog that asks the user to add an app signature in an existing passkey
*/
private fun showAppSignatureDialog(appOrigin: AppOrigin) {
private fun showAppSignatureDialog(
database: ContextualDatabase,
temptingApp: AppOrigin
) {
AlertDialog.Builder(this@PasskeyLauncherActivity).apply {
setTitle(getString(R.string.passkeys_missing_signature_app_ask_title))
setMessage(StringBuilder()
.append(
getString(
R.string.passkeys_missing_signature_app_ask_message,
appOrigin.toName()
temptingApp.toName()
)
)
.append("\n\n")
@@ -317,230 +211,24 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
.toString()
)
setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(CoroutineExceptionHandler { _, e ->
cancelRequest(e)
}) {
// TODO
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_OK
)
}
passkeyLauncherViewModel.saveAppSignature(
intent = intent,
specialMode = mSpecialMode,
database = database,
temptingApp = temptingApp
)
}
setNegativeButton(android.R.string.cancel) { _, _ ->
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
passkeyLauncherViewModel.cancelResult()
}
setOnCancelListener {
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
passkeyLauncherViewModel.cancelResult()
}
}.create().show()
}
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
super.onDatabaseRetrieved(database)
if (passkeyLauncherViewModel.isResultLauncherRegistered.not()) {
lifecycleScope.launch(CoroutineExceptionHandler { _, e ->
when (e) {
is PrivilegedAllowLists.PrivilegedException -> {
passkeyLauncherViewModel.showAppPrivilegedDialog(e.temptingApp)
}
else -> cancelRequest(e)
}
}) {
launchPasskeyAction(database)
}
}
}
private fun autoSelectPasskeyAndSetResult(
database: ContextualDatabase?,
nodeId: UUID,
appOrigin: AppOrigin
) {
mUsageParameters?.let { usageParameters ->
// To get the passkey from the database
val passkey = database
?.getEntryById(NodeIdUUID(nodeId))
?.getEntryInfo(database)
?.passkey
?: throw GetCredentialUnknownException("No passkey with nodeId $nodeId found")
val result = Intent()
try {
PendingIntentHandler.setGetCredentialResponse(
result,
GetCredentialResponse(
buildPasskeyPublicKeyCredential(
requestOptions = usageParameters.publicKeyCredentialRequestOptions,
clientDataResponse = getVerifiedGETClientDataResponse(
usageParameters = usageParameters,
appOrigin = appOrigin
),
passkey = passkey,
backupEligibility = mBackupEligibility,
backupState = mBackupState
)
)
)
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_OK,
data = result
)
} catch (e: SignatureNotFoundException) {
passkeyLauncherViewModel.showAppSignatureDialog(e.temptingApp)
}
} ?: run {
Log.e(TAG, "Unable to auto select passkey, usage parameters are empty")
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
}
private suspend fun launchSelection(
database: ContextualDatabase?,
nodeId: UUID?,
searchInfo: SearchInfo,
appOrigin: AppOrigin
) {
Log.d(TAG, "Launch passkey selection")
retrievePasskeyUsageRequestParameters(
intent = intent,
context = applicationContext
) { usageParameters ->
// Save the requested parameters
mUsageParameters = usageParameters
// Manage the passkey to use
nodeId?.let { nodeId ->
autoSelectPasskeyAndSetResult(database, nodeId, appOrigin)
} ?: run {
SearchHelper.checkAutoSearchInfo(
context = this,
database = database,
searchInfo = searchInfo,
onItemsFound = { _, _ ->
Log.w(
TAG, "Passkey found for auto selection, should not append," +
"use PasskeyProviderService instead"
)
finish()
},
onItemNotFound = { openedDatabase ->
Log.d(
TAG, "No Passkey found for selection," +
"launch manual selection in opened database"
)
GroupActivity.launchForPasskeySelectionResult(
context = this,
database = openedDatabase,
activityResultLauncher = mPasskeySelectionActivityResultLauncher,
searchInfo = null,
autoSearch = false
)
},
onDatabaseClosed = {
Log.d(TAG, "Manual passkey selection in closed database")
FileDatabaseSelectActivity.launchForPasskeySelectionResult(
activity = this,
activityResultLauncher = mPasskeySelectionActivityResultLauncher,
searchInfo = searchInfo,
)
}
)
}
}
}
private fun autoRegisterPasskeyAndSetResult(
database: ContextualDatabase?,
nodeId: UUID,
passkey: Passkey
) {
// TODO Overwrite and Register in a predefined group
mCreationParameters?.let { creationParameters ->
// To set the passkey to the database
setResult(RESULT_OK)
finish()
} ?: run {
Log.e(TAG, "Unable to auto select passkey, usage parameters are empty")
setResult(RESULT_CANCELED)
finish()
}
}
private suspend fun launchRegistration(
database: ContextualDatabase?,
nodeId: UUID?,
searchInfo: SearchInfo
) {
Log.d(TAG, "Launch passkey registration")
retrievePasskeyCreationRequestParameters(
intent = intent,
context = applicationContext,
passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters ->
// Save the requested parameters
mPasskey = passkey
mCreationParameters = publicKeyCredentialParameters
// Manage the passkey and create a register info
val registerInfo = RegisterInfo(
searchInfo = searchInfo,
passkey = passkey,
appOrigin = appInfoToStore
)
// If nodeId already provided
nodeId?.let { nodeId ->
autoRegisterPasskeyAndSetResult(database, nodeId, passkey)
} ?: run {
SearchHelper.checkAutoSearchInfo(
context = this,
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, _ ->
Log.w(TAG, "Passkey found for registration, " +
"but launch manual registration for a new entry")
GroupActivity.launchForRegistration(
context = this,
activityResultLauncher = mPasskeyRegistrationActivityResultLauncher,
database = openedDatabase,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
)
},
onItemNotFound = { openedDatabase ->
Log.d(TAG, "Launch new manual registration in opened database")
GroupActivity.launchForRegistration(
context = this,
activityResultLauncher = mPasskeyRegistrationActivityResultLauncher,
database = openedDatabase,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
)
},
onDatabaseClosed = {
Log.d(TAG, "Manual passkey registration in closed database")
FileDatabaseSelectActivity.launchForRegistration(
context = this,
activityResultLauncher = mPasskeyRegistrationActivityResultLauncher,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
)
}
)
}
}
)
}
private fun showError(e: Throwable) {
Log.e(TAG, "Passkey launch error", e)
Toast.makeText(this, e.localizedMessage, Toast.LENGTH_LONG).show()
}

View File

@@ -1,34 +1,512 @@
package com.kunzisoft.keepass.credentialprovider.viewmodel
import androidx.lifecycle.ViewModel
import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK
import android.app.Application
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.annotation.RequiresApi
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.checkSecurity
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.getVerifiedGETClientDataResponse
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removeAppOrigin
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveAppOrigin
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveSearchInfo
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.model.AppOrigin
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.model.SignatureNotFoundException
import com.kunzisoft.keepass.settings.PreferencesUtil
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
import java.io.InvalidObjectException
import java.util.UUID
class PasskeyLauncherViewModel: ViewModel() {
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeyLauncherViewModel(application: Application): AndroidViewModel(application) {
var isResultLauncherRegistered: Boolean = false
val lockDatabase = true
private var mUsageParameters: PublicKeyCredentialUsageParameters? = null
private var mCreationParameters: PublicKeyCredentialCreationParameters? = null
private var mPasskey: Passkey? = null
private var mBackupEligibility: Boolean = true
private var mBackupState: Boolean = false
private var mLockDatabase: Boolean = true
private var isResultLauncherRegistered: Boolean = false
private var currentDatabase: ContextualDatabase? = null
private val _uiState = MutableStateFlow<UIState>(UIState.Loading)
val uiState: StateFlow<UIState> = _uiState
fun showAppPrivilegedDialog(temptingApp: AndroidPrivilegedApp) {
_uiState.value = UIState.ShowAppPrivilegedDialog(temptingApp)
fun initialize() {
mBackupEligibility = PreferencesUtil.isPasskeyBackupEligibilityEnable(getApplication())
mBackupState = PreferencesUtil.isPasskeyBackupStateEnable(getApplication())
}
fun showAppSignatureDialog(temptingApp: AppOrigin) {
_uiState.value = UIState.ShowAppSignatureDialog(temptingApp)
fun showAppPrivilegedDialog(
database: ContextualDatabase,
temptingApp: AndroidPrivilegedApp
) {
_uiState.value = UIState.ShowAppPrivilegedDialog(database, temptingApp)
}
fun showAppSignatureDialog(
database: ContextualDatabase,
temptingApp: AppOrigin
) {
_uiState.value = UIState.ShowAppSignatureDialog(database, temptingApp)
}
fun showError(error: Throwable) {
_uiState.value = UIState.ShowError(error)
}
fun saveCustomPrivilegedApp(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase,
temptingApp: AndroidPrivilegedApp
) {
viewModelScope.launch(CoroutineExceptionHandler { _, e ->
cancelResult()
}) {
saveCustomPrivilegedApps(
context = getApplication(),
privilegedApps = listOf(temptingApp)
)
launchPasskeyAction(
intent = intent,
specialMode = specialMode,
database = database
)
}
}
fun saveAppSignature(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase,
temptingApp: AppOrigin
) {
viewModelScope.launch(CoroutineExceptionHandler { _, e ->
cancelResult()
}) {
// TODO Save app signature
}
}
fun setResult(intent: Intent) {
currentDatabase = null
_uiState.value = UIState.SetActivityResult(
lockDatabase = mLockDatabase,
resultCode = RESULT_OK,
data = intent
)
}
fun cancelResult() {
currentDatabase = null
_uiState.value = UIState.SetActivityResult(
lockDatabase = mLockDatabase,
resultCode = RESULT_CANCELED
)
}
fun onDatabaseRetrieved(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase?
) {
currentDatabase = database
if (isResultLauncherRegistered.not()) {
viewModelScope.launch(CoroutineExceptionHandler { _, e ->
if (e is PrivilegedAllowLists.PrivilegedException && database != null) {
showAppPrivilegedDialog(database, e.temptingApp)
} else {
showError(e)
cancelResult()
}
}) {
launchPasskeyAction(intent, specialMode, database)
}
}
}
/**
* Launch the main action to manage Passkey
*/
private suspend fun launchPasskeyAction(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase?
) {
isResultLauncherRegistered = true
val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo()
val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false)
val nodeId = intent.retrieveNodeId()
checkSecurity(intent, nodeId)
when (specialMode) {
SpecialMode.SELECTION -> {
launchSelection(
intent = intent,
database = database,
nodeId = nodeId,
searchInfo = searchInfo,
appOrigin = appOrigin
)
}
SpecialMode.REGISTRATION -> {
// TODO Registration in predefined group
// launchRegistration(database, nodeId, mSearchInfo)
launchRegistration(
intent = intent,
database = database,
nodeId = null,
searchInfo = searchInfo
)
}
else -> {
throw InvalidObjectException("Passkey launch mode not supported")
}
}
}
// -------------
// Selection
// -------------
private suspend fun launchSelection(
intent: Intent,
database: ContextualDatabase?,
nodeId: UUID?,
searchInfo: SearchInfo,
appOrigin: AppOrigin
) {
withContext(Dispatchers.IO) {
Log.d(TAG, "Launch passkey selection")
retrievePasskeyUsageRequestParameters(
intent = intent,
context = getApplication()
) { usageParameters ->
// Save the requested parameters
mUsageParameters = usageParameters
// Manage the passkey to use
nodeId?.let { nodeId ->
autoSelectPasskeyAndSetResult(database, nodeId, appOrigin)
} ?: run {
SearchHelper.checkAutoSearchInfo(
context = getApplication(),
database = database,
searchInfo = searchInfo,
onItemsFound = { _, _ ->
Log.w(
TAG, "Passkey found for auto selection, should not append," +
"use PasskeyProviderService instead"
)
cancelResult()
},
onItemNotFound = { openedDatabase ->
Log.d(
TAG, "No Passkey found for selection," +
"launch manual selection in opened database"
)
_uiState.value = UIState.LaunchGroupActivityForSelection(
database = openedDatabase
)
},
onDatabaseClosed = {
Log.d(TAG, "Manual passkey selection in closed database")
_uiState.value =
UIState.LaunchFileDatabaseSelectActivityForSelection(
searchInfo = searchInfo
)
}
)
}
}
}
}
private fun autoSelectPasskeyAndSetResult(
database: ContextualDatabase?,
nodeId: UUID,
appOrigin: AppOrigin
) {
mUsageParameters?.let { usageParameters ->
// To get the passkey from the database
val passkey = database
?.getEntryById(NodeIdUUID(nodeId))
?.getEntryInfo(database)
?.passkey
?: throw GetCredentialUnknownException(
"No passkey with nodeId $nodeId found"
)
// Build the response
val result = Intent()
try {
PendingIntentHandler.setGetCredentialResponse(
result,
GetCredentialResponse(
buildPasskeyPublicKeyCredential(
requestOptions = usageParameters.publicKeyCredentialRequestOptions,
clientDataResponse = getVerifiedGETClientDataResponse(
usageParameters = usageParameters,
appOrigin = appOrigin
),
passkey = passkey,
backupEligibility = mBackupEligibility,
backupState = mBackupState
)
)
)
setResult(result)
} catch (e: SignatureNotFoundException) {
// Request the dialog if signature exception
showAppSignatureDialog(database, e.temptingApp)
}
} ?: throw IOException("Usage parameters is null")
}
fun manageSelectionResult(
activityResult: ActivityResult
) {
val intent = activityResult.data
// Build a new formatted response from the selection response
val responseIntent = Intent()
when (activityResult.resultCode) {
RESULT_OK -> {
try {
Log.d(TAG, "Passkey selection result")
if (intent == null)
throw IOException("Intent is null")
val passkey = intent.retrievePasskey()
?: throw IOException("Passkey is null")
val appOrigin = intent.retrieveAppOrigin()
?: throw IOException("App origin is null")
intent.removePasskey()
intent.removeAppOrigin()
mUsageParameters?.let { usageParameters ->
// Check verified origin
PendingIntentHandler.setGetCredentialResponse(
responseIntent,
GetCredentialResponse(
buildPasskeyPublicKeyCredential(
requestOptions = usageParameters.publicKeyCredentialRequestOptions,
clientDataResponse = getVerifiedGETClientDataResponse(
usageParameters = usageParameters,
appOrigin = appOrigin
),
passkey = passkey,
backupEligibility = mBackupEligibility,
backupState = mBackupState
)
)
)
} ?: run {
throw IOException("Usage parameters is null")
}
setResult(responseIntent)
} catch (e: SignatureNotFoundException) {
currentDatabase?.let {
showAppSignatureDialog(it, e.temptingApp)
}
} catch (e: Exception) {
Log.e(TAG, "Unable to create selection response for passkey", e)
showError(e)
cancelResult()
}
}
RESULT_CANCELED -> {
cancelResult()
}
}
// Remove the launcher register
isResultLauncherRegistered = false
}
// -------------
// Registation
// -------------
private suspend fun launchRegistration(
intent: Intent,
database: ContextualDatabase?,
nodeId: UUID?,
searchInfo: SearchInfo
) {
withContext(Dispatchers.IO) {
Log.d(TAG, "Launch passkey registration")
retrievePasskeyCreationRequestParameters(
intent = intent,
context = getApplication(),
passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters ->
// Save the requested parameters
mPasskey = passkey
mCreationParameters = publicKeyCredentialParameters
// Manage the passkey and create a register info
val registerInfo = RegisterInfo(
searchInfo = searchInfo,
passkey = passkey,
appOrigin = appInfoToStore
)
// If nodeId already provided
nodeId?.let { nodeId ->
autoRegisterPasskeyAndSetResult(database, nodeId, passkey)
} ?: run {
SearchHelper.checkAutoSearchInfo(
context = getApplication(),
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, _ ->
Log.w(
TAG, "Passkey found for registration, " +
"but launch manual registration for a new entry"
)
_uiState.value = UIState.LaunchGroupActivityForRegistration(
database = openedDatabase,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
)
},
onItemNotFound = { openedDatabase ->
Log.d(TAG, "Launch new manual registration in opened database")
_uiState.value = UIState.LaunchGroupActivityForRegistration(
database = openedDatabase,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
)
},
onDatabaseClosed = {
Log.d(TAG, "Manual passkey registration in closed database")
_uiState.value =
UIState.LaunchFileDatabaseSelectActivityForRegistration(
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
)
}
)
}
}
)
}
}
private fun autoRegisterPasskeyAndSetResult(
database: ContextualDatabase?,
nodeId: UUID,
passkey: Passkey
) {
// TODO Overwrite and Register in a predefined group
mCreationParameters?.let { creationParameters ->
// To set the passkey to the database
setResult(Intent())
} ?: run {
Log.e(TAG, "Unable to auto select passkey, usage parameters are empty")
cancelResult()
}
}
fun manageRegistrationResult(activityResult: ActivityResult) {
val intent = activityResult.data
// Build a new formatted response from the creation response
val responseIntent = Intent()
try {
Log.d(TAG, "Passkey registration result")
val passkey = intent?.retrievePasskey()
intent?.removePasskey()
intent?.removeAppOrigin()
// If registered passkey is the same as the one we want to validate,
if (mPasskey == passkey) {
mCreationParameters?.let {
PendingIntentHandler.setCreateCredentialResponse(
intent = responseIntent,
response = buildCreatePublicKeyCredentialResponse(
publicKeyCredentialCreationParameters = it,
backupEligibility = mBackupEligibility,
backupState = mBackupState
)
)
}
} else {
throw SecurityException("Passkey was modified before registration")
}
} catch (e: Exception) {
Log.e(TAG, "Unable to create registration response for passkey", e)
_uiState.value = UIState.ShowError(e)
}
_uiState.value = UIState.SetActivityResult(
lockDatabase = mLockDatabase,
resultCode = activityResult.resultCode,
data = responseIntent
)
}
sealed class UIState {
object Loading : UIState()
data class ShowAppPrivilegedDialog(
val database: ContextualDatabase,
val temptingApp: AndroidPrivilegedApp
): UIState()
data class ShowAppSignatureDialog(
val database: ContextualDatabase,
val temptingApp: AppOrigin
): UIState()
data class LaunchGroupActivityForSelection(
val database: ContextualDatabase
): UIState()
data class LaunchGroupActivityForRegistration(
val database: ContextualDatabase,
val registerInfo: RegisterInfo,
val typeMode: TypeMode
): UIState()
data class LaunchFileDatabaseSelectActivityForSelection(
val searchInfo: SearchInfo
): UIState()
data class LaunchFileDatabaseSelectActivityForRegistration(
val registerInfo: RegisterInfo,
val typeMode: TypeMode
): UIState()
data class SetActivityResult(
val lockDatabase: Boolean,
val resultCode: Int,
val data: Intent? = null
): UIState()
data class ShowError(
val error: Throwable
): UIState()
}
companion object {
private val TAG = PasskeyLauncherViewModel::class.java.name
}
}

View File

@@ -429,7 +429,7 @@
<string name="passkeys_privileged_apps_ask_message">%1$s attempts to perform a Passkey action.\n\nAdd it to the list of privileged apps?</string>
<string name="passkeys_missing_signature_app_ask_title">Signature missing</string>
<string name="passkeys_missing_signature_app_ask_explanation">WARNING: The passkey was created from another client or the signature has been deleted. Ensure the app you want to authenticate is part of the same service and is legitimate to avoid security issues.</string>
<string name="passkeys_missing_signature_app_ask_message">%1$s with an unknown signature attempts to authenticate with an existing passkey.\n\nAdd app signature to passkey entry?</string>
<string name="passkeys_missing_signature_app_ask_message">%1$s is unrecognised and attempts to authenticate with an existing passkey.\n\nAdd app signature to passkey entry?</string>
<string name="passkeys_backup_eligibility_title">Backup Eligibility</string>
<string name="passkeys_backup_eligibility_summary">Determine at creation time whether the public key credential source is allowed to be backed up</string>
<string name="passkeys_backup_state_title">Backup State</string>

View File

@@ -44,12 +44,19 @@ data class AppOrigin(
this.webOrigins.add(webOrigin)
}
/**
* Determine whether at least one signature is present in the Android origins
*/
fun containsAndroidOriginSignature(): Boolean {
return androidOrigins.any { !it.fingerprint.isNullOrEmpty() }
}
/**
* Verify the app origin by comparing it to the list of android origins,
* return the first verified origin or throw an exception if none is found
*/
fun checkAppOrigin(compare: AppOrigin): String {
if (compare.androidOrigins.isNotEmpty()) {
if (compare.containsAndroidOriginSignature().not()) {
throw SignatureNotFoundException(this, "Android origin not found")
}
return androidOrigins.firstOrNull { androidOrigin ->