fix: Passkey auto save Signature

This commit is contained in:
J-Jamet
2025-09-17 23:23:41 +02:00
parent 0aecc21f43
commit 272ebd0c3f
9 changed files with 188 additions and 107 deletions

View File

@@ -69,7 +69,6 @@ import com.kunzisoft.keepass.database.element.Attachment
import com.kunzisoft.keepass.database.element.DateInstant
import com.kunzisoft.keepass.database.element.Entry
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.node.Node
import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.database.element.template.Template
import com.kunzisoft.keepass.education.EntryEditActivityEducation
@@ -81,9 +80,9 @@ import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.services.AttachmentFileNotificationService
import com.kunzisoft.keepass.services.ClipboardEntryNotificationService
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_CREATE_ENTRY_TASK
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getNewEntry
import com.kunzisoft.keepass.services.KeyboardEntryNotificationService
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.tasks.ActionRunnable
@@ -433,41 +432,35 @@ class EntryEditActivity : DatabaseLockActivity(),
ACTION_DATABASE_UPDATE_ENTRY_TASK -> {
try {
if (result.isSuccess) {
var newNodes: List<Node> = ArrayList()
result.data?.getBundle(DatabaseTaskNotificationService.NEW_NODES_KEY)?.let { newNodesBundle ->
newNodes = DatabaseTaskNotificationService.getListNodesFromBundle(database, newNodesBundle)
}
if (newNodes.size == 1) {
(newNodes[0] as? Entry?)?.let { entry ->
EntrySelectionHelper.doSpecialAction(
intent = intent,
defaultAction = {
// Finish naturally
finishForEntryResult(entry)
},
searchAction = {
// Nothing when search retrieved
},
saveAction = {
entryValidatedForSave(entry)
},
keyboardSelectionAction = {
entryValidatedForKeyboardSelection(database, entry)
},
autofillSelectionAction = { _, _ ->
entryValidatedForAutofillSelection(database, entry)
},
autofillRegistrationAction = {
entryValidatedForAutofillRegistration(entry)
},
passkeySelectionAction = {
entryValidatedForPasskeySelection(database, entry)
},
passkeyRegistrationAction = {
entryValidatedForPasskeyRegistration(database, entry)
}
)
}
result.data?.getNewEntry(database)?.let { entry ->
EntrySelectionHelper.doSpecialAction(
intent = intent,
defaultAction = {
// Finish naturally
finishForEntryResult(entry)
},
searchAction = {
// Nothing when search retrieved
},
saveAction = {
entryValidatedForSave(entry)
},
keyboardSelectionAction = {
entryValidatedForKeyboardSelection(database, entry)
},
autofillSelectionAction = { _, _ ->
entryValidatedForAutofillSelection(database, entry)
},
autofillRegistrationAction = {
entryValidatedForAutofillRegistration(entry)
},
passkeySelectionAction = {
entryValidatedForPasskeySelection(database, entry)
},
passkeyRegistrationAction = {
entryValidatedForPasskeyRegistration(database, entry)
}
)
}
}
} catch (e: Exception) {

View File

@@ -39,7 +39,6 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
@@ -93,8 +92,7 @@ import com.kunzisoft.keepass.model.GroupInfo
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.NEW_NODES_KEY
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getListNodesFromBundle
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.getNewEntry
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.settings.SettingsActivity
import com.kunzisoft.keepass.tasks.ActionRunnable
@@ -700,9 +698,7 @@ class GroupActivity : DatabaseLockActivity(),
var entry: Entry? = null
try {
result.data?.getBundle(NEW_NODES_KEY)?.let { newNodesBundle ->
entry = getListNodesFromBundle(database, newNodesBundle)[0] as Entry
}
entry = result.data?.getNewEntry(database)
} catch (e: Exception) {
Log.e(TAG, "Unable to retrieve entry action for selection", e)
}

View File

@@ -25,7 +25,7 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -35,7 +35,7 @@ import androidx.lifecycle.lifecycleScope
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.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addTypeMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult
@@ -50,11 +50,14 @@ import com.kunzisoft.keepass.credentialprovider.viewmodel.PasskeyLauncherViewMod
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.model.AppOrigin
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.services.DatabaseTaskNotificationService.Companion.ACTION_DATABASE_UPDATE_ENTRY_TASK
import com.kunzisoft.keepass.tasks.ActionRunnable
import com.kunzisoft.keepass.view.toastError
import kotlinx.coroutines.launch
import java.util.UUID
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeyLauncherActivity : DatabaseModeActivity() {
class PasskeyLauncherActivity : DatabaseLockActivity() {
private val passkeyLauncherViewModel: PasskeyLauncherViewModel by viewModels()
@@ -76,6 +79,10 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
return false
}
override fun finishActivityIfDatabaseNotLoaded(): Boolean {
return false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
@@ -89,14 +96,13 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
}
is PasskeyLauncherViewModel.UIState.ShowAppPrivilegedDialog -> {
showAppPrivilegedDialog(
database = uiState.database,
temptingApp = uiState.temptingApp
)
}
is PasskeyLauncherViewModel.UIState.ShowAppSignatureDialog -> {
showAppSignatureDialog(
database = uiState.database,
temptingApp = uiState.temptingApp
temptingApp = uiState.temptingApp,
nodeId = uiState.nodeId
)
}
is PasskeyLauncherViewModel.UIState.SetActivityResult -> {
@@ -107,7 +113,8 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
)
}
is PasskeyLauncherViewModel.UIState.ShowError -> {
showError(uiState.error)
toastError(uiState.error)
passkeyLauncherViewModel.cancelResult()
}
is PasskeyLauncherViewModel.UIState.LaunchGroupActivityForSelection -> {
GroupActivity.launchForPasskeySelectionResult(
@@ -142,6 +149,9 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
typeMode = uiState.typeMode
)
}
is PasskeyLauncherViewModel.UIState.UpdateEntry -> {
updateEntry(uiState.oldEntry, uiState.newEntry)
}
}
}
}
@@ -149,14 +159,30 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
super.onDatabaseRetrieved(database)
passkeyLauncherViewModel.onDatabaseRetrieved(intent, mSpecialMode, database)
passkeyLauncherViewModel.launchPasskeyActionIfNeeded(intent, mSpecialMode, database)
}
override fun onDatabaseActionFinished(
database: ContextualDatabase,
actionTask: String,
result: ActionRunnable.Result
) {
super.onDatabaseActionFinished(database, actionTask, result)
when (actionTask) {
ACTION_DATABASE_UPDATE_ENTRY_TASK -> {
passkeyLauncherViewModel.autoSelectPasskey(result, database)
}
}
}
override fun viewToInvalidateTimeout(): View? {
return null
}
/**
* Display a dialog that asks the user to add an app to the list of privileged apps.
*/
private fun showAppPrivilegedDialog(
database: ContextualDatabase,
temptingApp: AndroidPrivilegedApp
) {
Log.w(javaClass.simpleName, "No privileged apps file found")
@@ -177,7 +203,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
passkeyLauncherViewModel.saveCustomPrivilegedApp(
intent = intent,
specialMode = mSpecialMode,
database = database,
database = mDatabase,
temptingApp = temptingApp
)
}
@@ -194,8 +220,8 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
* Display a dialog that asks the user to add an app signature in an existing passkey
*/
private fun showAppSignatureDialog(
database: ContextualDatabase,
temptingApp: AppOrigin
temptingApp: AppOrigin,
nodeId: UUID
) {
AlertDialog.Builder(this@PasskeyLauncherActivity).apply {
setTitle(getString(R.string.passkeys_missing_signature_app_ask_title))
@@ -203,7 +229,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
.append(
getString(
R.string.passkeys_missing_signature_app_ask_message,
temptingApp.toName()
temptingApp.toString()
)
)
.append("\n\n")
@@ -212,10 +238,9 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
)
setPositiveButton(android.R.string.ok) { _, _ ->
passkeyLauncherViewModel.saveAppSignature(
intent = intent,
specialMode = mSpecialMode,
database = database,
temptingApp = temptingApp
database = mDatabase,
temptingApp = temptingApp,
nodeId = nodeId
)
}
setNegativeButton(android.R.string.cancel) { _, _ ->
@@ -227,11 +252,6 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
}.create().show()
}
private fun showError(e: Throwable) {
Log.e(TAG, "Passkey launch error", e)
Toast.makeText(this, e.localizedMessage, Toast.LENGTH_LONG).show()
}
companion object {
private val TAG = PasskeyLauncherActivity::class.java.name

View File

@@ -24,5 +24,5 @@ import com.kunzisoft.keepass.model.AppOrigin
data class PublicKeyCredentialUsageParameters(
val publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions,
val clientDataResponse: ClientDataResponse,
val appOrigin: AppOrigin
var appOrigin: AppOrigin
)

View File

@@ -118,11 +118,12 @@ object PasskeyHelper {
extras: Bundle? = null
) {
try {
entryInfo.passkey?.let {
entryInfo.passkey?.let { passkey ->
val mReplyIntent = Intent()
Log.d(javaClass.name, "Success Passkey manual selection")
mReplyIntent.putExtra(EXTRA_PASSKEY, entryInfo.passkey)
mReplyIntent.putExtra(EXTRA_APP_ORIGIN, entryInfo.appOrigin)
mReplyIntent.addPasskey(passkey)
mReplyIntent.addAppOrigin(entryInfo.appOrigin)
mReplyIntent.addNodeId(entryInfo.id)
extras?.let {
mReplyIntent.putExtras(it)
}
@@ -157,6 +158,15 @@ object PasskeyHelper {
})
}
/**
* Add the passkey to the intent
*/
fun Intent.addPasskey(passkey: Passkey?) {
passkey?.let {
putExtra(EXTRA_PASSKEY, passkey)
}
}
/**
* Retrieve the passkey from the intent
*/

View File

@@ -33,14 +33,18 @@ import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retri
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.Entry
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.exception.RegisterInReadOnlyDatabaseException
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.services.DatabaseTaskNotificationService.Companion.getNewEntry
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.tasks.ActionRunnable
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -63,7 +67,6 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
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
@@ -74,31 +77,31 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
}
fun showAppPrivilegedDialog(
database: ContextualDatabase,
temptingApp: AndroidPrivilegedApp
) {
_uiState.value = UIState.ShowAppPrivilegedDialog(database, temptingApp)
_uiState.value = UIState.ShowAppPrivilegedDialog(temptingApp)
}
fun showAppSignatureDialog(
database: ContextualDatabase,
temptingApp: AppOrigin
temptingApp: AppOrigin,
nodeId: UUID
) {
_uiState.value = UIState.ShowAppSignatureDialog(database, temptingApp)
_uiState.value = UIState.ShowAppSignatureDialog(temptingApp, nodeId)
}
fun showError(error: Throwable) {
Log.e(TAG, "Error on passkey launch", error)
_uiState.value = UIState.ShowError(error)
}
fun saveCustomPrivilegedApp(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase,
database: ContextualDatabase?,
temptingApp: AndroidPrivilegedApp
) {
viewModelScope.launch(CoroutineExceptionHandler { _, e ->
cancelResult()
showError(e)
}) {
saveCustomPrivilegedApps(
context = getApplication(),
@@ -113,20 +116,39 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
}
fun saveAppSignature(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase,
temptingApp: AppOrigin
database: ContextualDatabase?,
temptingApp: AppOrigin,
nodeId: UUID
) {
viewModelScope.launch(CoroutineExceptionHandler { _, e ->
cancelResult()
showError(e)
}) {
// TODO Save app signature
// Update the entry with app signature
val entry = database
?.getEntryById(NodeIdUUID(nodeId))
?: throw GetCredentialUnknownException(
"No passkey with nodeId $nodeId found"
)
if (database.isReadOnly)
throw RegisterInReadOnlyDatabaseException()
val newEntry = Entry(entry)
val entryInfo = newEntry.getEntryInfo(
database,
raw = true,
removeTemplateConfiguration = false
)
entryInfo.saveAppOrigin(database, temptingApp)
newEntry.setEntryInfo(database, entryInfo)
_uiState.value = UIState.UpdateEntry(
oldEntry = entry,
newEntry = newEntry
)
}
}
fun setResult(intent: Intent) {
currentDatabase = null
// Remove the launcher register
isResultLauncherRegistered = false
_uiState.value = UIState.SetActivityResult(
lockDatabase = mLockDatabase,
resultCode = RESULT_OK,
@@ -135,26 +157,25 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
}
fun cancelResult() {
currentDatabase = null
isResultLauncherRegistered = false
_uiState.value = UIState.SetActivityResult(
lockDatabase = mLockDatabase,
resultCode = RESULT_CANCELED
)
}
fun onDatabaseRetrieved(
fun launchPasskeyActionIfNeeded(
intent: Intent,
specialMode: SpecialMode,
database: ContextualDatabase?
) {
currentDatabase = database
if (isResultLauncherRegistered.not()) {
isResultLauncherRegistered = true
viewModelScope.launch(CoroutineExceptionHandler { _, e ->
if (e is PrivilegedAllowLists.PrivilegedException && database != null) {
showAppPrivilegedDialog(database, e.temptingApp)
if (e is PrivilegedAllowLists.PrivilegedException) {
showAppPrivilegedDialog(e.temptingApp)
} else {
showError(e)
cancelResult()
}
}) {
launchPasskeyAction(intent, specialMode, database)
@@ -170,7 +191,6 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
specialMode: SpecialMode,
database: ContextualDatabase?
) {
isResultLauncherRegistered = true
val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo()
val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false)
val nodeId = intent.retrieveNodeId()
@@ -257,6 +277,27 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
}
}
fun autoSelectPasskey(
result: ActionRunnable.Result,
database: ContextualDatabase
) {
viewModelScope.launch(CoroutineExceptionHandler { _, e ->
showError(e)
}) {
if (result.isSuccess) {
val entry = result.data?.getNewEntry(database)
?: throw IOException("No passkey entry found")
autoSelectPasskeyAndSetResult(
database = database,
nodeId = entry.nodeId.id,
appOrigin = entry.getAppOrigin()
?: throw IOException("No App origin found")
)
} else throw result.exception
?: IOException("Unable to auto select passkey")
}
}
private fun autoSelectPasskeyAndSetResult(
database: ContextualDatabase?,
nodeId: UUID,
@@ -268,7 +309,7 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
?.getEntryById(NodeIdUUID(nodeId))
?.getEntryInfo(database)
?.passkey
?: throw GetCredentialUnknownException(
?: throw IOException(
"No passkey with nodeId $nodeId found"
)
// Build the response
@@ -292,7 +333,7 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
setResult(result)
} catch (e: SignatureNotFoundException) {
// Request the dialog if signature exception
showAppSignatureDialog(database, e.temptingApp)
showAppSignatureDialog(e.temptingApp, nodeId)
}
} ?: throw IOException("Usage parameters is null")
}
@@ -337,21 +378,18 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
}
setResult(responseIntent)
} catch (e: SignatureNotFoundException) {
currentDatabase?.let {
showAppSignatureDialog(it, e.temptingApp)
}
intent?.retrieveNodeId()?.let { nodeId ->
showAppSignatureDialog(e.temptingApp, nodeId)
} ?: cancelResult()
} 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
}
// -------------
@@ -474,12 +512,11 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
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
val temptingApp: AppOrigin,
val nodeId: UUID
): UIState()
data class LaunchGroupActivityForSelection(
val database: ContextualDatabase
@@ -504,6 +541,10 @@ class PasskeyLauncherViewModel(application: Application): AndroidViewModel(appli
data class ShowError(
val error: Throwable
): UIState()
data class UpdateEntry(
val oldEntry: Entry,
val newEntry: Entry
): UIState()
}
companion object {

View File

@@ -1385,6 +1385,15 @@ open class DatabaseTaskNotificationService : LockNotificationService(), Progress
return nodesAction
}
fun Bundle.getNewEntry(database: ContextualDatabase): Entry? {
getBundle(NEW_NODES_KEY)
?.getParcelableList<NodeId<UUID>>(ENTRIES_ID_KEY)
?.get(0)?.let {
return database.getEntryById(it)
}
return null
}
fun getBundleFromListNodes(nodes: List<Node>): Bundle {
val groupsId = mutableListOf<NodeId<*>>()
val entriesId = mutableListOf<NodeId<UUID>>()

View File

@@ -89,6 +89,14 @@ data class AppOrigin(
} else null
}
override fun toString(): String {
return if (androidOrigins.isNotEmpty()) {
androidOrigins.first().toString()
} else if (webOrigins.isNotEmpty()) {
webOrigins.first().toString()
} else super.toString()
}
companion object {
private val TAG = AppOrigin::class.java.simpleName

View File

@@ -213,15 +213,19 @@ class EntryInfo : NodeInfo {
registerInfo.password?.let { password = it }
setCreditCard(registerInfo.creditCard)
setPasskey(registerInfo.passkey)
setAppOrigin(
registerInfo.appOrigin,
database?.allowEntryCustomFields() == true
)
saveAppOrigin(database, registerInfo.appOrigin)
if (title.isEmpty()) {
title = registerInfo.toString().toTitle()
}
}
/**
* Add AppOrigin
*/
fun saveAppOrigin(database: Database?, appOrigin: AppOrigin?) {
setAppOrigin(appOrigin, database?.allowEntryCustomFields() == true)
}
fun getVisualTitle(): String {
return title.ifEmpty {
url.ifEmpty {