fix: Refactoring Credential Provider

This commit is contained in:
J-Jamet
2025-07-16 14:03:48 +02:00
parent 488fd60d5d
commit d1f463d497
59 changed files with 2496 additions and 1897 deletions

View File

@@ -161,7 +161,7 @@
<activity <activity
android:name="com.kunzisoft.keepass.settings.SettingsActivity" /> android:name="com.kunzisoft.keepass.settings.SettingsActivity" />
<activity <activity
android:name="com.kunzisoft.keepass.activities.AutofillLauncherActivity" android:name="com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity"
android:theme="@style/Theme.Transparent" android:theme="@style/Theme.Transparent"
android:configChanges="keyboardHidden" android:configChanges="keyboardHidden"
android:excludeFromRecents="true"/> android:excludeFromRecents="true"/>
@@ -175,7 +175,7 @@
android:name="com.kunzisoft.keepass.hardware.HardwareKeyActivity" android:name="com.kunzisoft.keepass.hardware.HardwareKeyActivity"
android:theme="@style/Theme.Transparent" /> android:theme="@style/Theme.Transparent" />
<activity <activity
android:name="com.kunzisoft.keepass.activities.EntrySelectionLauncherActivity" android:name="com.kunzisoft.keepass.credentialprovider.activity.EntrySelectionLauncherActivity"
android:theme="@style/Theme.Transparent" android:theme="@style/Theme.Transparent"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:exported="true"> android:exported="true">
@@ -201,19 +201,13 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name="com.kunzisoft.keepass.credentialprovider.activity.CreatePasskeyActivity" android:name="com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity"
android:label="CreatePasskeyActivity" android:theme="@style/Theme.Transparent"
android:exported="true" android:configChanges="keyboardHidden"
android:excludeFromRecents="true"
android:exported="false"
tools:targetApi="upside_down_cake" /> tools:targetApi="upside_down_cake" />
<activity
android:name="com.kunzisoft.keepass.credentialprovider.activity.UsePasskeyActivity"
android:label="UsePasskeyActivity"
android:exported="true"
tools:targetApi="upside_down_cake" />
<service <service
android:name="com.kunzisoft.keepass.services.DatabaseTaskNotificationService" android:name="com.kunzisoft.keepass.services.DatabaseTaskNotificationService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
@@ -241,7 +235,7 @@
android:exported="false" /> android:exported="false" />
<!-- Receiver for Autofill --> <!-- Receiver for Autofill -->
<service <service
android:name="com.kunzisoft.keepass.autofill.KeeAutofillService" android:name="com.kunzisoft.keepass.credentialprovider.autofill.KeeAutofillService"
android:label="@string/autofill_service_name" android:label="@string/autofill_service_name"
android:exported="true" android:exported="true"
android:permission="android.permission.BIND_AUTOFILL_SERVICE"> android:permission="android.permission.BIND_AUTOFILL_SERVICE">
@@ -253,7 +247,7 @@
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name="com.kunzisoft.keepass.magikeyboard.MagikeyboardService" android:name="com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService"
android:label="@string/keyboard_label" android:label="@string/keyboard_label"
android:exported="true" android:exported="true"
android:permission="android.permission.BIND_INPUT_METHOD" > android:permission="android.permission.BIND_INPUT_METHOD" >
@@ -263,12 +257,11 @@
<action android:name="android.view.InputMethod" /> <action android:name="android.view.InputMethod" />
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name="com.kunzisoft.keepass.credentialprovider.service.KeePassDXCredentialProviderService" android:name="com.kunzisoft.keepass.credentialprovider.passkey.PasskeyProviderService"
android:enabled="true" android:enabled="true"
android:exported="true" android:exported="true"
android:label="KeePassDX Credential Provider" android:label="@string/passkey_service_name"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE" android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
tools:targetApi="upside_down_cake"> tools:targetApi="upside_down_cake">

View File

@@ -23,7 +23,6 @@ import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -38,13 +37,10 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat import androidx.core.graphics.BlendModeCompat
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
@@ -54,7 +50,7 @@ import com.google.android.material.tabs.TabLayout
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.fragments.EntryFragment import com.kunzisoft.keepass.activities.fragments.EntryFragment
import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.adapters.TagsAdapter import com.kunzisoft.keepass.adapters.TagsAdapter
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
@@ -62,7 +58,7 @@ import com.kunzisoft.keepass.database.element.Attachment
import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.database.element.icon.IconImage
import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.education.EntryActivityEducation import com.kunzisoft.keepass.education.EntryActivityEducation
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.model.EntryAttachmentState
import com.kunzisoft.keepass.otp.OtpType import com.kunzisoft.keepass.otp.OtpType
import com.kunzisoft.keepass.services.AttachmentFileNotificationService import com.kunzisoft.keepass.services.AttachmentFileNotificationService

View File

@@ -55,12 +55,15 @@ import com.kunzisoft.keepass.activities.dialogs.FileTooBigDialogFragment.Compani
import com.kunzisoft.keepass.activities.dialogs.ReplaceFileDialogFragment import com.kunzisoft.keepass.activities.dialogs.ReplaceFileDialogFragment
import com.kunzisoft.keepass.activities.dialogs.SetOTPDialogFragment import com.kunzisoft.keepass.activities.dialogs.SetOTPDialogFragment
import com.kunzisoft.keepass.activities.fragments.EntryEditFragment import com.kunzisoft.keepass.activities.fragments.EntryEditFragment
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper
import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.adapters.TemplatesSelectorAdapter import com.kunzisoft.keepass.adapters.TemplatesSelectorAdapter
import com.kunzisoft.keepass.autofill.AutofillComponent import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.autofill.AutofillHelper import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper
import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyResponseAndSetResult
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.Attachment
import com.kunzisoft.keepass.database.element.DateInstant import com.kunzisoft.keepass.database.element.DateInstant
@@ -70,7 +73,6 @@ import com.kunzisoft.keepass.database.element.node.Node
import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.database.element.template.Template import com.kunzisoft.keepass.database.element.template.Template
import com.kunzisoft.keepass.education.EntryEditActivityEducation import com.kunzisoft.keepass.education.EntryEditActivityEducation
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.model.AttachmentState import com.kunzisoft.keepass.model.AttachmentState
import com.kunzisoft.keepass.model.DataTime import com.kunzisoft.keepass.model.DataTime
import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.model.EntryAttachmentState
@@ -376,18 +378,25 @@ class EntryEditActivity : DatabaseLockActivity(),
// Don't wait for saving if it's to provide autofill // Don't wait for saving if it's to provide autofill
mDatabase?.let { database -> mDatabase?.let { database ->
EntrySelectionHelper.doSpecialAction(intent, EntrySelectionHelper.doSpecialAction(
{}, intent = intent,
{}, defaultAction = {},
{}, searchAction = {},
{ saveAction = {},
keyboardSelectionAction = {
entryValidatedForKeyboardSelection(database, entrySave.newEntry) entryValidatedForKeyboardSelection(database, entrySave.newEntry)
}, },
{ _, _ -> autofillSelectionAction = { _, _ ->
entryValidatedForAutofillSelection(database, entrySave.newEntry) entryValidatedForAutofillSelection(database, entrySave.newEntry)
}, },
{ autofillRegistrationAction = {
entryValidatedForAutofillRegistration(entrySave.newEntry) entryValidatedForAutofillRegistration(entrySave.newEntry)
},
passkeySelectionAction = {
entryValidatedForPasskeySelection(database, entrySave.newEntry)
},
passkeyRegistrationAction = {
entryValidatedForPasskeyRegistration(database, entrySave.newEntry)
} }
) )
} }
@@ -430,25 +439,32 @@ class EntryEditActivity : DatabaseLockActivity(),
} }
if (newNodes.size == 1) { if (newNodes.size == 1) {
(newNodes[0] as? Entry?)?.let { entry -> (newNodes[0] as? Entry?)?.let { entry ->
EntrySelectionHelper.doSpecialAction(intent, EntrySelectionHelper.doSpecialAction(
{ intent = intent,
defaultAction = {
// Finish naturally // Finish naturally
finishForEntryResult(entry) finishForEntryResult(entry)
}, },
{ searchAction = {
// Nothing when search retrieved // Nothing when search retrieved
}, },
{ saveAction = {
entryValidatedForSave(entry) entryValidatedForSave(entry)
}, },
{ keyboardSelectionAction = {
entryValidatedForKeyboardSelection(database, entry) entryValidatedForKeyboardSelection(database, entry)
}, },
{ _, _ -> autofillSelectionAction = { _, _ ->
entryValidatedForAutofillSelection(database, entry) entryValidatedForAutofillSelection(database, entry)
}, },
{ autofillRegistrationAction = {
entryValidatedForAutofillRegistration(entry) entryValidatedForAutofillRegistration(entry)
},
passkeySelectionAction = {
entryValidatedForPasskeySelection(database, entry)
},
passkeyRegistrationAction = {
entryValidatedForPasskeyRegistration(database, entry)
} }
) )
} }
@@ -488,9 +504,33 @@ class EntryEditActivity : DatabaseLockActivity(),
onValidateSpecialMode() onValidateSpecialMode()
} }
private fun entryValidatedForAutofillRegistration(entry: Entry) { private fun entryValidatedForPasskeySelection(database: ContextualDatabase, entry: Entry) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
this.buildPasskeyResponseAndSetResult(
entryInfo = entry.getEntryInfo(database)
)
}
onValidateSpecialMode()
}
private fun entryValidatedForAutofillRegistration(entry: Entry) {
//if (isIntentSender()) {
// TODO Autofill Callback #765
//}
onValidateSpecialMode()
if (!isIntentSender()) {
finishForEntryResult(entry)
}
}
private fun entryValidatedForPasskeyRegistration(database: ContextualDatabase, entry: Entry) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
this.buildPasskeyResponseAndSetResult(
entryInfo = entry.getEntryInfo(database),
extras = buildEntryResult(entry) // To update the previous screen
)
}
onValidateSpecialMode() onValidateSpecialMode()
finishForEntryResult(entry)
} }
override fun onResume() { override fun onResume() {
@@ -742,12 +782,17 @@ class EntryEditActivity : DatabaseLockActivity(),
} }
} }
private fun buildEntryResult(entry: Entry): Bundle {
return Bundle().apply {
putParcelable(ADD_OR_UPDATE_ENTRY_KEY, entry.nodeId)
}
}
private fun finishForEntryResult(entry: Entry) { private fun finishForEntryResult(entry: Entry) {
// Assign entry callback as a result // Assign entry callback as a result
try { try {
val bundle = Bundle() val bundle = buildEntryResult(entry)
val intentEntry = Intent() val intentEntry = Intent()
bundle.putParcelable(ADD_OR_UPDATE_ENTRY_KEY, entry.nodeId)
intentEntry.putExtras(bundle) intentEntry.putExtras(bundle)
setResult(Activity.RESULT_OK, intentEntry) setResult(Activity.RESULT_OK, intentEntry)
super.finish() super.finish()
@@ -892,7 +937,7 @@ class EntryEditActivity : DatabaseLockActivity(),
if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) {
val intent = Intent(activity, EntryEditActivity::class.java) val intent = Intent(activity, EntryEditActivity::class.java)
intent.putExtra(KEY_PARENT, groupId) intent.putExtra(KEY_PARENT, groupId)
AutofillHelper.startActivityForAutofillResult( EntrySelectionHelper.startActivityForAutofillSelectionModeResult(
activity, activity,
intent, intent,
activityResultLauncher, activityResultLauncher,
@@ -903,21 +948,48 @@ class EntryEditActivity : DatabaseLockActivity(),
} }
} }
/**
* Launch EntryEditActivity to add a new passkey entry
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun launchForPasskeySelectionResult(context: Context,
database: ContextualDatabase,
activityResultLauncher: ActivityResultLauncher<Intent>?,
groupId: NodeId<*>,
searchInfo: SearchInfo? = null) {
if (database.loaded && !database.isReadOnly) {
if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) {
val intent = Intent(context, EntryEditActivity::class.java)
intent.putExtra(KEY_PARENT, groupId)
EntrySelectionHelper.startActivityForPasskeySelectionModeResult(
context,
intent,
activityResultLauncher,
searchInfo
)
}
}
}
/** /**
* Launch EntryEditActivity to register an updated entry (from autofill) * Launch EntryEditActivity to register an updated entry (from autofill)
*/ */
fun launchToUpdateForRegistration(context: Context, fun launchToUpdateForRegistration(context: Context,
database: ContextualDatabase, database: ContextualDatabase,
activityResultLauncher: ActivityResultLauncher<Intent>?,
entryId: NodeId<UUID>, entryId: NodeId<UUID>,
registerInfo: RegisterInfo? = null) { registerInfo: RegisterInfo?,
typeMode: TypeMode) {
if (database.loaded && !database.isReadOnly) { if (database.loaded && !database.isReadOnly) {
if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) {
val intent = Intent(context, EntryEditActivity::class.java) val intent = Intent(context, EntryEditActivity::class.java)
intent.putExtra(KEY_ENTRY, entryId) intent.putExtra(KEY_ENTRY, entryId)
EntrySelectionHelper.startActivityForRegistrationModeResult( EntrySelectionHelper.startActivityForRegistrationModeResult(
context, context,
activityResultLauncher,
intent, intent,
registerInfo registerInfo,
typeMode
) )
} }
} }
@@ -928,16 +1000,20 @@ class EntryEditActivity : DatabaseLockActivity(),
*/ */
fun launchToCreateForRegistration(context: Context, fun launchToCreateForRegistration(context: Context,
database: ContextualDatabase, database: ContextualDatabase,
activityResultLauncher: ActivityResultLauncher<Intent>?,
groupId: NodeId<*>, groupId: NodeId<*>,
registerInfo: RegisterInfo? = null) { registerInfo: RegisterInfo? = null,
typeMode: TypeMode) {
if (database.loaded && !database.isReadOnly) { if (database.loaded && !database.isReadOnly) {
if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) {
val intent = Intent(context, EntryEditActivity::class.java) val intent = Intent(context, EntryEditActivity::class.java)
intent.putExtra(KEY_PARENT, groupId) intent.putExtra(KEY_PARENT, groupId)
EntrySelectionHelper.startActivityForRegistrationModeResult( EntrySelectionHelper.startActivityForRegistrationModeResult(
context, context,
activityResultLauncher,
intent, intent,
registerInfo registerInfo,
typeMode
) )
} }
} }

View File

@@ -44,15 +44,16 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.dialogs.SetMainCredentialDialogFragment import com.kunzisoft.keepass.activities.dialogs.SetMainCredentialDialogFragment
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.activities.helpers.setOpenDocumentClickListener import com.kunzisoft.keepass.activities.helpers.setOpenDocumentClickListener
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.adapters.FileDatabaseHistoryAdapter import com.kunzisoft.keepass.adapters.FileDatabaseHistoryAdapter
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
import com.kunzisoft.keepass.autofill.AutofillComponent import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.autofill.AutofillHelper import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.MainCredential
import com.kunzisoft.keepass.education.FileDatabaseSelectActivityEducation import com.kunzisoft.keepass.education.FileDatabaseSelectActivityEducation
@@ -99,10 +100,8 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
private var mExternalFileHelper: ExternalFileHelper? = null private var mExternalFileHelper: ExternalFileHelper? = null
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? = private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) this.buildActivityResultLauncher()
AutofillHelper.buildActivityResultLauncher(this)
else null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -299,7 +298,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
}, },
{ onCancelSpecialMode() }, { onCancelSpecialMode() },
{ onLaunchActivitySpecialMode() }, { onLaunchActivitySpecialMode() },
mAutofillActivityResultLauncher) mCredentialActivityResultLauncher)
} }
private fun launchGroupActivityIfLoaded(database: ContextualDatabase) { private fun launchGroupActivityIfLoaded(database: ContextualDatabase) {
@@ -309,7 +308,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
{ onValidateSpecialMode() }, { onValidateSpecialMode() },
{ onCancelSpecialMode() }, { onCancelSpecialMode() },
{ onLaunchActivitySpecialMode() }, { onLaunchActivitySpecialMode() },
mAutofillActivityResultLauncher) mCredentialActivityResultLauncher)
} }
} }
@@ -493,23 +492,46 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
activityResultLauncher: ActivityResultLauncher<Intent>?, activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent, autofillComponent: AutofillComponent,
searchInfo: SearchInfo? = null) { searchInfo: SearchInfo? = null) {
AutofillHelper.startActivityForAutofillResult(activity, EntrySelectionHelper.startActivityForAutofillSelectionModeResult(activity,
Intent(activity, FileDatabaseSelectActivity::class.java), Intent(activity, FileDatabaseSelectActivity::class.java),
activityResultLauncher, activityResultLauncher,
autofillComponent, autofillComponent,
searchInfo) searchInfo)
} }
/*
* -------------------------
* Passkey Launch
* -------------------------
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun launchForPasskeySelectionResult(activity: Activity,
activityResultLauncher: ActivityResultLauncher<Intent>?,
searchInfo: SearchInfo? = null) {
EntrySelectionHelper.startActivityForPasskeySelectionModeResult(
activity,
Intent(activity, FileDatabaseSelectActivity::class.java),
activityResultLauncher,
searchInfo
)
}
/* /*
* ------------------------- * -------------------------
* Registration Launch * Registration Launch
* ------------------------- * -------------------------
*/ */
fun launchForRegistration(context: Context, fun launchForRegistration(context: Context,
registerInfo: RegisterInfo? = null) { activityResultLauncher: ActivityResultLauncher<Intent>?,
EntrySelectionHelper.startActivityForRegistrationModeResult(context, registerInfo: RegisterInfo? = null,
Intent(context, FileDatabaseSelectActivity::class.java), typeMode: TypeMode) {
registerInfo) EntrySelectionHelper.startActivityForRegistrationModeResult(
context,
activityResultLauncher,
Intent(context, FileDatabaseSelectActivity::class.java),
registerInfo,
typeMode
)
} }
} }
} }

View File

@@ -63,13 +63,15 @@ import com.kunzisoft.keepass.activities.dialogs.GroupEditDialogFragment
import com.kunzisoft.keepass.activities.dialogs.MainCredentialDialogFragment import com.kunzisoft.keepass.activities.dialogs.MainCredentialDialogFragment
import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment
import com.kunzisoft.keepass.activities.fragments.GroupFragment import com.kunzisoft.keepass.activities.fragments.GroupFragment
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.adapters.BreadcrumbAdapter import com.kunzisoft.keepass.adapters.BreadcrumbAdapter
import com.kunzisoft.keepass.autofill.AutofillComponent import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.autofill.AutofillHelper import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.MainCredential
import com.kunzisoft.keepass.database.element.DateInstant import com.kunzisoft.keepass.database.element.DateInstant
@@ -83,7 +85,8 @@ import com.kunzisoft.keepass.database.element.node.Type
import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.education.GroupActivityEducation import com.kunzisoft.keepass.education.GroupActivityEducation
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyResponseAndSetResult
import com.kunzisoft.keepass.model.DataTime import com.kunzisoft.keepass.model.DataTime
import com.kunzisoft.keepass.model.GroupInfo import com.kunzisoft.keepass.model.GroupInfo
import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.RegisterInfo
@@ -264,10 +267,8 @@ class GroupActivity : DatabaseLockActivity(),
mGroupEditViewModel.selectIcon(icon) mGroupEditViewModel.selectIcon(icon)
} }
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? = private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) this.buildActivityResultLauncher()
AutofillHelper.buildActivityResultLauncher(this)
else null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -484,59 +485,87 @@ class GroupActivity : DatabaseLockActivity(),
addNodeButtonView?.setAddEntryClickListener { addNodeButtonView?.setAddEntryClickListener {
mDatabase?.let { database -> mDatabase?.let { database ->
mMainGroup?.let { currentGroup -> mMainGroup?.let { currentGroup ->
EntrySelectionHelper.doSpecialAction(intent, EntrySelectionHelper.doSpecialAction(
{ intent = intent,
defaultAction = {
mMainGroup?.nodeId?.let { currentParentGroupId -> mMainGroup?.nodeId?.let { currentParentGroupId ->
EntryEditActivity.launchToCreate( EntryEditActivity.launchToCreate(
this@GroupActivity, activity = this@GroupActivity,
database, database = database,
currentParentGroupId, groupId = currentParentGroupId,
mEntryActivityResultLauncher activityResultLauncher = mEntryActivityResultLauncher
) )
} }
}, },
{ searchAction = {
// Search not used // Search not used
}, },
{ searchInfo -> saveAction = { searchInfo ->
EntryEditActivity.launchToCreateForSave( EntryEditActivity.launchToCreateForSave(
this@GroupActivity, context = this@GroupActivity,
database, database = database,
currentGroup.nodeId, groupId = currentGroup.nodeId,
searchInfo searchInfo = searchInfo
) )
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
}, },
{ searchInfo -> keyboardSelectionAction = { searchInfo ->
EntryEditActivity.launchForKeyboardSelectionResult( EntryEditActivity.launchForKeyboardSelectionResult(
this@GroupActivity, context = this@GroupActivity,
database, database = database,
currentGroup.nodeId, groupId = currentGroup.nodeId,
searchInfo searchInfo = searchInfo
) )
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
}, },
{ searchInfo, autofillComponent -> autofillSelectionAction = { searchInfo, autofillComponent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
EntryEditActivity.launchForAutofillResult( EntryEditActivity.launchForAutofillResult(
this@GroupActivity, activity = this@GroupActivity,
database, database = database,
mAutofillActivityResultLauncher, activityResultLauncher = mCredentialActivityResultLauncher,
autofillComponent, autofillComponent = autofillComponent,
currentGroup.nodeId, groupId = currentGroup.nodeId,
searchInfo searchInfo = searchInfo
) )
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
} else { } else {
onCancelSpecialMode() onCancelSpecialMode()
} }
}, },
{ searchInfo -> autofillRegistrationAction = { registerInfo ->
EntryEditActivity.launchToCreateForRegistration( EntryEditActivity.launchToCreateForRegistration(
this@GroupActivity, context = this@GroupActivity,
database, database = database,
currentGroup.nodeId, activityResultLauncher = null,
searchInfo groupId = currentGroup.nodeId,
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL
)
onLaunchActivitySpecialMode()
},
passkeySelectionAction = { searchInfo ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
EntryEditActivity.launchForPasskeySelectionResult(
context = this@GroupActivity,
database = database,
activityResultLauncher = mCredentialActivityResultLauncher,
groupId = currentGroup.nodeId,
searchInfo = searchInfo,
)
onLaunchActivitySpecialMode()
} else {
onCancelSpecialMode()
}
},
passkeyRegistrationAction = { registerInfo ->
EntryEditActivity.launchToCreateForRegistration(
context = this@GroupActivity,
database = database,
activityResultLauncher = mCredentialActivityResultLauncher,
groupId = currentGroup.nodeId,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
) )
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
} }
@@ -679,30 +708,40 @@ class GroupActivity : DatabaseLockActivity(),
when (actionTask) { when (actionTask) {
ACTION_DATABASE_UPDATE_ENTRY_TASK -> { ACTION_DATABASE_UPDATE_ENTRY_TASK -> {
if (result.isSuccess) { if (result.isSuccess) {
EntrySelectionHelper.doSpecialAction(intent, EntrySelectionHelper.doSpecialAction(
{ intent = intent,
defaultAction = {
// Standard not used after task // Standard not used after task
}, },
{ searchAction = {
// Search not used // Search not used
}, },
{ saveAction = {
// Save not used // Save not used
}, },
{ keyboardSelectionAction = {
// Keyboard selection // Keyboard selection
entry?.let { entry?.let {
entrySelectedForKeyboardSelection(database, it) entrySelectedForKeyboardSelection(database, it)
} }
}, },
{ _, _ -> autofillSelectionAction = { _, _ ->
// Autofill selection // Autofill selection
entry?.let { entry?.let {
entrySelectedForAutofillSelection(database, it) entrySelectedForAutofillSelection(database, it)
} }
}, },
{ autofillRegistrationAction = {
// Not use // Not use
},
passkeySelectionAction = {
// Passkey selection
entry?.let {
entrySelectedForPasskeySelection(database, it)
}
},
passkeyRegistrationAction = {
// TODO Passkey Registration
} }
) )
} }
@@ -846,27 +885,28 @@ class GroupActivity : DatabaseLockActivity(),
Type.ENTRY -> try { Type.ENTRY -> try {
val entryVersioned = node as Entry val entryVersioned = node as Entry
EntrySelectionHelper.doSpecialAction(intent, EntrySelectionHelper.doSpecialAction(
{ intent = intent,
defaultAction = {
EntryActivity.launch( EntryActivity.launch(
this@GroupActivity, activity = this@GroupActivity,
database, database = database,
entryVersioned.nodeId, entryId = entryVersioned.nodeId,
mEntryActivityResultLauncher activityResultLauncher = mEntryActivityResultLauncher
) )
// Do not reload group here // Do not reload group here
}, },
{ searchAction = {
// Nothing here, a search is simply performed // Nothing here, a search is simply performed
}, },
{ searchInfo -> saveAction = { searchInfo ->
if (!database.isReadOnly) { if (!database.isReadOnly) {
entrySelectedForSave(database, entryVersioned, searchInfo) entrySelectedForSave(database, entryVersioned, searchInfo)
loadGroup() loadGroup()
} else } else
finish() finish()
}, },
{ searchInfo -> keyboardSelectionAction = { searchInfo ->
if (!database.isReadOnly if (!database.isReadOnly
&& searchInfo != null && searchInfo != null
&& PreferencesUtil.isKeyboardSaveSearchInfoEnable(this@GroupActivity) && PreferencesUtil.isKeyboardSaveSearchInfoEnable(this@GroupActivity)
@@ -876,7 +916,7 @@ class GroupActivity : DatabaseLockActivity(),
entrySelectedForKeyboardSelection(database, entryVersioned) entrySelectedForKeyboardSelection(database, entryVersioned)
loadGroup() loadGroup()
}, },
{ searchInfo, _ -> autofillSelectionAction = { searchInfo, _ ->
if (!database.isReadOnly if (!database.isReadOnly
&& searchInfo != null && searchInfo != null
&& PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity) && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity)
@@ -886,9 +926,39 @@ class GroupActivity : DatabaseLockActivity(),
entrySelectedForAutofillSelection(database, entryVersioned) entrySelectedForAutofillSelection(database, entryVersioned)
loadGroup() loadGroup()
}, },
{ registerInfo -> autofillRegistrationAction = { registerInfo ->
if (!database.isReadOnly) { if (!database.isReadOnly) {
entrySelectedForRegistration(database, entryVersioned, registerInfo) entrySelectedForRegistration(
database = database,
entry = entryVersioned,
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL,
activityResultLauncher = null // TODO Result launcher autofill #765
)
loadGroup()
} else
finish()
},
passkeySelectionAction = { searchInfo ->
if (!database.isReadOnly
&& searchInfo != null
// TODO Passkey setting && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity)
) {
updateEntryWithSearchInfo(database, entryVersioned, searchInfo)
}
entrySelectedForPasskeySelection(database, entryVersioned)
loadGroup()
},
passkeyRegistrationAction = { registerInfo ->
if (!database.isReadOnly) {
// TODO Passkey setting && PreferencesUtil.isAutofillOverwriteEnable(this@GroupActivity)
entrySelectedForRegistration(
database = database,
entry = entryVersioned,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY,
activityResultLauncher = mCredentialActivityResultLauncher
)
loadGroup() loadGroup()
} else } else
finish() finish()
@@ -934,18 +1004,33 @@ class GroupActivity : DatabaseLockActivity(),
onValidateSpecialMode() onValidateSpecialMode()
} }
private fun entrySelectedForPasskeySelection(database: ContextualDatabase, entry: Entry) {
removeSearch()
// Build response with the entry selected
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
buildPasskeyResponseAndSetResult(
entryInfo = entry.getEntryInfo(database)
)
}
onValidateSpecialMode()
}
private fun entrySelectedForRegistration( private fun entrySelectedForRegistration(
database: ContextualDatabase, database: ContextualDatabase,
entry: Entry, entry: Entry,
registerInfo: RegisterInfo? activityResultLauncher: ActivityResultLauncher<Intent>?,
registerInfo: RegisterInfo?,
typeMode: TypeMode
) { ) {
removeSearch() removeSearch()
// Registration to update the entry // Registration to update the entry
EntryEditActivity.launchToUpdateForRegistration( EntryEditActivity.launchToUpdateForRegistration(
this@GroupActivity, context = this@GroupActivity,
database, database = database,
entry.nodeId, activityResultLauncher = activityResultLauncher,
registerInfo entryId = entry.nodeId,
registerInfo = registerInfo,
typeMode = typeMode
) )
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
} }
@@ -1569,19 +1654,19 @@ class GroupActivity : DatabaseLockActivity(),
* ------------------------- * -------------------------
*/ */
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
fun launchForAutofillResult(activity: AppCompatActivity, fun launchForAutofillSelectionResult(activity: AppCompatActivity,
database: ContextualDatabase, database: ContextualDatabase,
activityResultLaunch: ActivityResultLauncher<Intent>?, activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent, autofillComponent: AutofillComponent,
searchInfo: SearchInfo? = null, searchInfo: SearchInfo? = null,
autoSearch: Boolean = false) { autoSearch: Boolean = false) {
if (database.loaded) { if (database.loaded) {
checkTimeAndBuildIntent(activity, null) { intent -> checkTimeAndBuildIntent(activity, null) { intent ->
intent.putExtra(AUTO_SEARCH_KEY, autoSearch) intent.putExtra(AUTO_SEARCH_KEY, autoSearch)
AutofillHelper.startActivityForAutofillResult( EntrySelectionHelper.startActivityForAutofillSelectionModeResult(
activity, activity,
intent, intent,
activityResultLaunch, activityResultLauncher,
autofillComponent, autofillComponent,
searchInfo searchInfo
) )
@@ -1589,21 +1674,49 @@ class GroupActivity : DatabaseLockActivity(),
} }
} }
/*
* -------------------------
* Passkey Launch
* -------------------------
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun launchForPasskeySelectionResult(context: Context,
database: ContextualDatabase,
activityResultLauncher: ActivityResultLauncher<Intent>?,
searchInfo: SearchInfo? = null,
autoSearch: Boolean = false) {
if (database.loaded) {
checkTimeAndBuildIntent(context, null) { intent ->
intent.putExtra(AUTO_SEARCH_KEY, autoSearch)
EntrySelectionHelper.startActivityForPasskeySelectionModeResult(
context,
intent,
activityResultLauncher,
searchInfo
)
}
}
}
/* /*
* ------------------------- * -------------------------
* Registration Launch * Registration Launch
* ------------------------- * -------------------------
*/ */
fun launchForRegistration(context: Context, fun launchForRegistration(context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>?,
database: ContextualDatabase, database: ContextualDatabase,
registerInfo: RegisterInfo? = null) { registerInfo: RegisterInfo? = null,
typeMode: TypeMode) {
if (database.loaded && !database.isReadOnly) { if (database.loaded && !database.isReadOnly) {
checkTimeAndBuildIntent(context, null) { intent -> checkTimeAndBuildIntent(context, null) { intent ->
intent.putExtra(AUTO_SEARCH_KEY, false) intent.putExtra(AUTO_SEARCH_KEY, false)
EntrySelectionHelper.startActivityForRegistrationModeResult( EntrySelectionHelper.startActivityForRegistrationModeResult(
context, context,
activityResultLauncher,
intent, intent,
registerInfo registerInfo,
typeMode
) )
} }
} }
@@ -1619,153 +1732,231 @@ class GroupActivity : DatabaseLockActivity(),
onValidateSpecialMode: () -> Unit, onValidateSpecialMode: () -> Unit,
onCancelSpecialMode: () -> Unit, onCancelSpecialMode: () -> Unit,
onLaunchActivitySpecialMode: () -> Unit, onLaunchActivitySpecialMode: () -> Unit,
autofillActivityResultLauncher: ActivityResultLauncher<Intent>?) { activityResultLauncher: ActivityResultLauncher<Intent>?) {
EntrySelectionHelper.doSpecialAction(activity.intent, EntrySelectionHelper.doSpecialAction(
{ intent = activity.intent,
// Default action defaultAction = {
launch( // Default action
activity, launch(
activity,
database,
true
)
},
searchAction = { searchInfo ->
// Search action
if (database.loaded) {
launchForSearchResult(activity,
database, database,
true searchInfo,
) true)
}, onLaunchActivitySpecialMode()
{ searchInfo -> } else {
// Search action // Simply close if database not opened
if (database.loaded) { onCancelSpecialMode()
launchForSearchResult(activity, }
},
saveAction = { searchInfo ->
// Save info
if (database.loaded) {
if (!database.isReadOnly) {
launchForSaveResult(
activity,
database, database,
searchInfo, searchInfo,
true) false
)
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
} else { } else {
// Simply close if database not opened Toast.makeText(
activity.applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG
)
.show()
onCancelSpecialMode() onCancelSpecialMode()
} }
}, }
{ searchInfo -> },
// Save info keyboardSelectionAction = { searchInfo ->
if (database.loaded) { // Keyboard selection
if (!database.isReadOnly) { SearchHelper.checkAutoSearchInfo(
launchForSaveResult( context = activity,
activity, database = database,
database, searchInfo = searchInfo,
searchInfo, onItemsFound = { _, items ->
false MagikeyboardService.performSelection(
) items,
onLaunchActivitySpecialMode() { entryInfo ->
} else { // Keyboard populated
Toast.makeText( MagikeyboardService.populateKeyboardAndMoveAppToBackground(
activity.applicationContext, activity,
R.string.autofill_read_only_save, entryInfo
Toast.LENGTH_LONG
)
.show()
onCancelSpecialMode()
}
}
},
{ searchInfo ->
// Keyboard selection
SearchHelper.checkAutoSearchInfo(activity,
database,
searchInfo,
{ _, items ->
MagikeyboardService.performSelection(
items,
{ entryInfo ->
// Keyboard populated
MagikeyboardService.populateKeyboardAndMoveAppToBackground(
activity,
entryInfo
)
onValidateSpecialMode()
},
{ autoSearch ->
launchForKeyboardSelectionResult(activity,
database,
searchInfo,
autoSearch)
onLaunchActivitySpecialMode()
}
) )
onValidateSpecialMode()
}, },
{ { autoSearch ->
// Here no search info found, disable auto search
launchForKeyboardSelectionResult(activity, launchForKeyboardSelectionResult(activity,
database, database,
searchInfo, searchInfo,
false) autoSearch)
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
},
{
// Simply close if database not opened, normally not happened
onCancelSpecialMode()
} }
)
},
onItemNotFound = {
// Here no search info found, disable auto search
launchForKeyboardSelectionResult(activity,
database,
searchInfo,
false)
onLaunchActivitySpecialMode()
},
onDatabaseClosed = {
// Simply close if database not opened, normally not happened
onCancelSpecialMode()
}
)
},
autofillSelectionAction = { searchInfo, autofillComponent ->
// Autofill selection
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
SearchHelper.checkAutoSearchInfo(
context = activity,
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, items ->
// Response is build
AutofillHelper.buildResponseAndSetResult(activity, openedDatabase, items)
onValidateSpecialMode()
},
onItemNotFound = {
// Here no search info found, disable auto search
launchForAutofillSelectionResult(
activity = activity,
database = database,
autofillComponent = autofillComponent,
searchInfo = searchInfo,
autoSearch = false,
activityResultLauncher = activityResultLauncher)
onLaunchActivitySpecialMode()
},
onDatabaseClosed = {
// Simply close if database not opened, normally not happened
onCancelSpecialMode()
}
) )
}, } else {
{ searchInfo, autofillComponent -> onCancelSpecialMode()
// Autofill selection }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { },
SearchHelper.checkAutoSearchInfo(activity, autofillRegistrationAction = { registerInfo ->
database, // Autofill registration
searchInfo, if (!database.isReadOnly) {
{ openedDatabase, items -> SearchHelper.checkAutoSearchInfo(
// Response is build context = activity,
AutofillHelper.buildResponseAndSetResult(activity, openedDatabase, items) database = database,
searchInfo = registerInfo?.searchInfo,
onItemsFound = { _, _ ->
// No auto search, it's a registration
launchForRegistration(
context = activity,
activityResultLauncher = null, // TODO Autofill result Launcher #765
database = database,
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL
)
onLaunchActivitySpecialMode()
},
onItemNotFound = {
// Here no search info found, disable auto search
launchForRegistration(
context = activity,
activityResultLauncher = null, // TODO Autofill result Launcher #765
database = database,
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL
)
onLaunchActivitySpecialMode()
},
onDatabaseClosed = {
// Simply close if database not opened, normally not happened
onCancelSpecialMode()
}
)
} else {
Toast.makeText(activity.applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
onCancelSpecialMode()
}
},
passkeySelectionAction = { searchInfo ->
// Passkey selection
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
SearchHelper.checkAutoSearchInfo(
context = activity,
database = database,
searchInfo = searchInfo,
onItemsFound = { _, items ->
// Response is build
EntrySelectionHelper.performSelection(
items = items,
actionPopulateCredentialProvider = { entryInfo ->
activity.buildPasskeyResponseAndSetResult(entryInfo)
onValidateSpecialMode() onValidateSpecialMode()
}, },
{ actionEntrySelection = {
// Here no search info found, disable auto search launchForPasskeySelectionResult(
launchForAutofillResult(activity, context = activity,
database, database = database,
autofillActivityResultLauncher, searchInfo = searchInfo,
autofillComponent, activityResultLauncher = activityResultLauncher
searchInfo, )
false)
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
},
{
// Simply close if database not opened, normally not happened
onCancelSpecialMode()
} }
) )
} else { },
onCancelSpecialMode() onItemNotFound = {
} // Here no search info found, disable auto search
}, launchForPasskeySelectionResult(
{ registerInfo -> context = activity,
// Autofill registration database = database,
if (!database.isReadOnly) { searchInfo = searchInfo,
SearchHelper.checkAutoSearchInfo(activity, activityResultLauncher = activityResultLauncher
database, )
registerInfo?.searchInfo, onLaunchActivitySpecialMode()
{ _, _ -> },
// No auto search, it's a registration onDatabaseClosed = {
launchForRegistration(activity, // Simply close if database not opened, normally not happened
database, onCancelSpecialMode()
registerInfo) }
onLaunchActivitySpecialMode() )
}, } else {
{ onCancelSpecialMode()
// Here no search info found, disable auto search }
launchForRegistration(activity, },
database, passkeyRegistrationAction = { registerInfo ->
registerInfo) // Passkey registration
onLaunchActivitySpecialMode() if (!database.isReadOnly) {
}, launchForRegistration(
{ context = activity,
// Simply close if database not opened, normally not happened activityResultLauncher = activityResultLauncher,
onCancelSpecialMode() database = database,
} registerInfo = registerInfo,
) typeMode = TypeMode.PASSKEY
} else { )
Toast.makeText(activity.applicationContext, onLaunchActivitySpecialMode()
R.string.autofill_read_only_save, } else {
Toast.LENGTH_LONG) Toast.makeText(activity.applicationContext,
.show() R.string.autofill_read_only_save,
onCancelSpecialMode() Toast.LENGTH_LONG)
} .show()
}) onCancelSpecialMode()
}
}
)
} }
} }
} }

View File

@@ -46,15 +46,16 @@ import androidx.fragment.app.commit
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.dialogs.DuplicateUuidDialog import com.kunzisoft.keepass.activities.dialogs.DuplicateUuidDialog
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.biometric.AdvancedUnlockFragment import com.kunzisoft.keepass.biometric.AdvancedUnlockFragment
import com.kunzisoft.keepass.biometric.AdvancedUnlockManager import com.kunzisoft.keepass.biometric.AdvancedUnlockManager
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.MainCredential
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
@@ -113,10 +114,8 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
private var mReadOnly: Boolean = false private var mReadOnly: Boolean = false
private var mForceReadOnly: Boolean = false private var mForceReadOnly: Boolean = false
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? = private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) this.buildActivityResultLauncher()
AutofillHelper.buildActivityResultLauncher(this)
else null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -395,7 +394,7 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
{ onValidateSpecialMode() }, { onValidateSpecialMode() },
{ onCancelSpecialMode() }, { onCancelSpecialMode() },
{ onLaunchActivitySpecialMode() }, { onLaunchActivitySpecialMode() },
mAutofillActivityResultLauncher mCredentialActivityResultLauncher
) )
} }
} }
@@ -806,14 +805,14 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
@Throws(FileNotFoundException::class) @Throws(FileNotFoundException::class)
fun launchForAutofillResult(activity: AppCompatActivity, fun launchForAutofillResult(activity: AppCompatActivity,
activityResultLauncher: ActivityResultLauncher<Intent>?,
databaseFile: Uri, databaseFile: Uri,
keyFile: Uri?, keyFile: Uri?,
hardwareKey: HardwareKey?, hardwareKey: HardwareKey?,
activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent, autofillComponent: AutofillComponent,
searchInfo: SearchInfo?) { searchInfo: SearchInfo?) {
buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent ->
AutofillHelper.startActivityForAutofillResult( EntrySelectionHelper.startActivityForAutofillSelectionModeResult(
activity, activity,
intent, intent,
activityResultLauncher, activityResultLauncher,
@@ -822,21 +821,51 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
} }
} }
/*
* -------------------------
* Passkey Launch
* -------------------------
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Throws(FileNotFoundException::class)
fun launchForPasskeyResult(activity: Activity,
activityResultLauncher: ActivityResultLauncher<Intent>?,
databaseFile: Uri,
keyFile: Uri?,
hardwareKey: HardwareKey?,
searchInfo: SearchInfo?) {
buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent ->
EntrySelectionHelper.startActivityForPasskeySelectionModeResult(
activity,
intent,
activityResultLauncher,
searchInfo
)
}
}
/* /*
* ------------------------- * -------------------------
* Registration Launch * Registration Launch
* ------------------------- * -------------------------
*/ */
fun launchForRegistration(activity: Activity, fun launchForRegistration(
databaseFile: Uri, activity: Activity,
keyFile: Uri?, activityResultLauncher: ActivityResultLauncher<Intent>?,
hardwareKey: HardwareKey?, databaseFile: Uri,
registerInfo: RegisterInfo?) { keyFile: Uri?,
hardwareKey: HardwareKey?,
typeMode: TypeMode,
registerInfo: RegisterInfo?
) {
buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent ->
EntrySelectionHelper.startActivityForRegistrationModeResult( EntrySelectionHelper.startActivityForRegistrationModeResult(
activity, context = activity,
intent, activityResultLauncher = activityResultLauncher,
registerInfo) intent = intent,
typeMode = typeMode,
registerInfo = registerInfo
)
} }
} }
@@ -852,74 +881,104 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
fileNoFoundAction: (exception: FileNotFoundException) -> Unit, fileNoFoundAction: (exception: FileNotFoundException) -> Unit,
onCancelSpecialMode: () -> Unit, onCancelSpecialMode: () -> Unit,
onLaunchActivitySpecialMode: () -> Unit, onLaunchActivitySpecialMode: () -> Unit,
autofillActivityResultLauncher: ActivityResultLauncher<Intent>?) { activityResultLauncher: ActivityResultLauncher<Intent>?) {
try { try {
EntrySelectionHelper.doSpecialAction(activity.intent, EntrySelectionHelper.doSpecialAction(
{ intent = activity.intent,
launch( defaultAction = {
activity, launch(
databaseUri, activity = activity,
keyFile, databaseFile = databaseUri,
hardwareKey keyFile = keyFile,
) hardwareKey = hardwareKey
}, )
{ searchInfo -> // Search Action },
launchForSearchResult( searchAction = { searchInfo ->
activity, launchForSearchResult(
databaseUri, activity = activity,
keyFile, databaseFile = databaseUri,
hardwareKey, keyFile = keyFile,
searchInfo hardwareKey = hardwareKey,
) searchInfo = searchInfo
onLaunchActivitySpecialMode() )
}, onLaunchActivitySpecialMode()
{ searchInfo -> // Save Action },
launchForSaveResult( saveAction = { searchInfo ->
activity, launchForSaveResult(
databaseUri, activity = activity,
keyFile, databaseFile = databaseUri,
hardwareKey, keyFile = keyFile,
searchInfo hardwareKey = hardwareKey,
) searchInfo = searchInfo
onLaunchActivitySpecialMode() )
}, onLaunchActivitySpecialMode()
{ searchInfo -> // Keyboard Selection Action },
launchForKeyboardResult( keyboardSelectionAction = { searchInfo ->
activity, launchForKeyboardResult(
databaseUri, activity = activity,
keyFile, databaseFile = databaseUri,
hardwareKey, keyFile = keyFile,
searchInfo hardwareKey = hardwareKey,
) searchInfo = searchInfo
onLaunchActivitySpecialMode() )
}, onLaunchActivitySpecialMode()
{ searchInfo, autofillComponent -> // Autofill Selection Action },
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { autofillSelectionAction = { searchInfo, autofillComponent ->
launchForAutofillResult( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity, launchForAutofillResult(
databaseUri, activity = activity,
keyFile, activityResultLauncher = activityResultLauncher,
hardwareKey, databaseFile = databaseUri,
autofillActivityResultLauncher, keyFile = keyFile,
autofillComponent, hardwareKey = hardwareKey,
searchInfo autofillComponent = autofillComponent,
) searchInfo = searchInfo
onLaunchActivitySpecialMode()
} else {
onCancelSpecialMode()
}
},
{ registerInfo -> // Registration Action
launchForRegistration(
activity,
databaseUri,
keyFile,
hardwareKey,
registerInfo
) )
onLaunchActivitySpecialMode() onLaunchActivitySpecialMode()
} else {
onCancelSpecialMode()
} }
},
autofillRegistrationAction = { registerInfo ->
launchForRegistration(
activity = activity,
activityResultLauncher = activityResultLauncher,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey,
typeMode = TypeMode.AUTOFILL,
registerInfo = registerInfo
)
onLaunchActivitySpecialMode()
},
passkeySelectionAction = { searchInfo ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
launchForPasskeyResult(
activity = activity,
activityResultLauncher = activityResultLauncher,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
} else {
onCancelSpecialMode()
}
},
passkeyRegistrationAction = { registerInfo ->
launchForRegistration(
activity = activity,
activityResultLauncher = activityResultLauncher,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey,
typeMode = TypeMode.PASSKEY,
registerInfo = registerInfo
)
onLaunchActivitySpecialMode()
}
) )
} catch (e: FileNotFoundException) { } catch (e: FileNotFoundException) {
fileNoFoundAction(e) fileNoFoundAction(e)

View File

@@ -36,8 +36,8 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.adapters.NodesAdapter import com.kunzisoft.keepass.adapters.NodesAdapter
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.Group

View File

@@ -1,5 +0,0 @@
package com.kunzisoft.keepass.activities.helpers
enum class TypeMode {
DEFAULT, MAGIKEYBOARD, AUTOFILL
}

View File

@@ -34,8 +34,8 @@ import androidx.appcompat.app.AlertDialog
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.dialogs.DeleteNodesDialogFragment import com.kunzisoft.keepass.activities.dialogs.DeleteNodesDialogFragment
import com.kunzisoft.keepass.activities.dialogs.PasswordEncodingDialogFragment import com.kunzisoft.keepass.activities.dialogs.PasswordEncodingDialogFragment
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.MainCredential
import com.kunzisoft.keepass.database.element.Entry import com.kunzisoft.keepass.database.element.Entry

View File

@@ -5,9 +5,10 @@ import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.isIntentSenderMode
import com.kunzisoft.keepass.activities.helpers.TypeMode import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.view.ToolbarSpecial import com.kunzisoft.keepass.view.ToolbarSpecial
@@ -42,18 +43,15 @@ abstract class DatabaseModeActivity : DatabaseActivity() {
/** /**
* Intent sender uses special retains data in callback * Intent sender uses special retains data in callback
*/ */
private fun isIntentSender(): Boolean { protected fun isIntentSender(): Boolean {
return (mSpecialMode == SpecialMode.SELECTION return isIntentSenderMode(mSpecialMode, mTypeMode)
&& mTypeMode == TypeMode.AUTOFILL)
/* TODO Registration callback #765
|| (mSpecialMode == SpecialMode.REGISTRATION
&& mTypeMode == TypeMode.AUTOFILL
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
*/
} }
fun onLaunchActivitySpecialMode() { fun onLaunchActivitySpecialMode() {
if (!isIntentSender()) { // TODO Verify behavior for Autofill Callback #765
if (isIntentSender()) {
onValidateSpecialMode()
} else {
EntrySelectionHelper.removeModesFromIntent(intent) EntrySelectionHelper.removeModesFromIntent(intent)
EntrySelectionHelper.removeInfoFromIntent(intent) EntrySelectionHelper.removeInfoFromIntent(intent)
finish() finish()
@@ -136,6 +134,7 @@ abstract class DatabaseModeActivity : DatabaseActivity() {
TypeMode.DEFAULT, // Not important because hidden TypeMode.DEFAULT, // Not important because hidden
TypeMode.MAGIKEYBOARD -> R.string.magic_keyboard_title TypeMode.MAGIKEYBOARD -> R.string.magic_keyboard_title
TypeMode.AUTOFILL -> R.string.autofill TypeMode.AUTOFILL -> R.string.autofill
TypeMode.PASSKEY -> R.string.passkey
} }
title = getString(selectionModeStringId) title = getString(selectionModeStringId)
if (mTypeMode != TypeMode.DEFAULT) if (mTypeMode != TypeMode.DEFAULT)

View File

@@ -17,17 +17,32 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>. * along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
package com.kunzisoft.keepass.activities.helpers package com.kunzisoft.keepass.credentialprovider
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import com.kunzisoft.keepass.autofill.AutofillComponent import android.util.Log
import com.kunzisoft.keepass.autofill.AutofillHelper import android.widget.RemoteViews
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper.addAutofillComponent
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.utils.getParcelableExtraCompat import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.LOCK_ACTION
import com.kunzisoft.keepass.utils.getEnumExtra import com.kunzisoft.keepass.utils.getEnumExtra
import com.kunzisoft.keepass.utils.getParcelableExtraCompat
import com.kunzisoft.keepass.utils.putEnumExtra import com.kunzisoft.keepass.utils.putEnumExtra
object EntrySelectionHelper { object EntrySelectionHelper {
@@ -37,6 +52,33 @@ object EntrySelectionHelper {
private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO"
private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO" private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO"
/**
* Utility method to build a registerForActivityResult,
* Used recursively, close each activity with return data
*/
fun AppCompatActivity.buildActivityResultLauncher(
lockDatabase: Boolean = false,
dataTransformation: (data: Intent?) -> Intent? = { it },
): ActivityResultLauncher<Intent> {
return this.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
val resultCode = it.resultCode
if (resultCode == Activity.RESULT_OK) {
this.setResult(resultCode, dataTransformation(it.data))
}
if (resultCode == Activity.RESULT_CANCELED) {
this.setResult(Activity.RESULT_CANCELED)
}
this.finish()
if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(this)) {
// Close the database
this.sendBroadcast(Intent(LOCK_ACTION))
}
}
}
fun startActivityForSearchModeResult(context: Context, fun startActivityForSearchModeResult(context: Context,
intent: Intent, intent: Intent,
searchInfo: SearchInfo) { searchInfo: SearchInfo) {
@@ -66,15 +108,52 @@ object EntrySelectionHelper {
context.startActivity(intent) context.startActivity(intent)
} }
fun startActivityForRegistrationModeResult(context: Context, /**
intent: Intent, * Utility method to start an activity with an Autofill for result
registerInfo: RegisterInfo?) { */
addSpecialModeInIntent(intent, SpecialMode.REGISTRATION) @RequiresApi(Build.VERSION_CODES.O)
// At the moment, only autofill for registration fun startActivityForAutofillSelectionModeResult(
context: Context,
intent: Intent,
activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent,
searchInfo: SearchInfo?
) {
addSpecialModeInIntent(intent, SpecialMode.SELECTION)
addTypeModeInIntent(intent, TypeMode.AUTOFILL) addTypeModeInIntent(intent, TypeMode.AUTOFILL)
intent.addAutofillComponent(context, autofillComponent)
addSearchInfoInIntent(intent, searchInfo)
activityResultLauncher?.launch(intent)
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun startActivityForPasskeySelectionModeResult(
context: Context,
intent: Intent,
activityResultLauncher: ActivityResultLauncher<Intent>?,
searchInfo: SearchInfo?
) {
addSpecialModeInIntent(intent, SpecialMode.SELECTION)
addTypeModeInIntent(intent, TypeMode.PASSKEY)
addSearchInfoInIntent(intent, searchInfo)
activityResultLauncher?.launch(intent)
}
fun startActivityForRegistrationModeResult(
context: Context?,
activityResultLauncher: ActivityResultLauncher<Intent>?,
intent: Intent,
registerInfo: RegisterInfo?,
typeMode: TypeMode
) {
addSpecialModeInIntent(intent, SpecialMode.REGISTRATION)
addTypeModeInIntent(intent, typeMode)
addRegisterInfoInIntent(intent, registerInfo) addRegisterInfoInIntent(intent, registerInfo)
intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK if (activityResultLauncher == null) {
context.startActivity(intent) intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
context?.startActivity(intent) ?: activityResultLauncher?.launch(intent) ?:
throw IllegalStateException("At least Context or ActivityResultLauncher must not be null")
} }
fun addSearchInfoInIntent(intent: Intent, searchInfo: SearchInfo?) { fun addSearchInfoInIntent(intent: Intent, searchInfo: SearchInfo?) {
@@ -103,8 +182,13 @@ object EntrySelectionHelper {
} }
fun addSpecialModeInIntent(intent: Intent, specialMode: SpecialMode) { fun addSpecialModeInIntent(intent: Intent, specialMode: SpecialMode) {
// TODO Replace by Intent.addSpecialMode
intent.putEnumExtra(KEY_SPECIAL_MODE, specialMode) intent.putEnumExtra(KEY_SPECIAL_MODE, specialMode)
} }
fun Intent.addSpecialMode(specialMode: SpecialMode): Intent {
this.putEnumExtra(KEY_SPECIAL_MODE, specialMode)
return this
}
fun retrieveSpecialModeFromIntent(intent: Intent): SpecialMode { fun retrieveSpecialModeFromIntent(intent: Intent): SpecialMode {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -131,6 +215,17 @@ object EntrySelectionHelper {
intent.removeExtra(KEY_TYPE_MODE) intent.removeExtra(KEY_TYPE_MODE)
} }
/**
* Intent sender uses special retains data in callback
*/
fun isIntentSenderMode(specialMode: SpecialMode, typeMode: TypeMode): Boolean {
return (specialMode == SpecialMode.SELECTION
&& (typeMode == TypeMode.AUTOFILL || typeMode == TypeMode.PASSKEY))
// TODO Autofill Registration callback #765 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|| (specialMode == SpecialMode.REGISTRATION
&& typeMode == TypeMode.PASSKEY)
}
fun doSpecialAction(intent: Intent, fun doSpecialAction(intent: Intent,
defaultAction: () -> Unit, defaultAction: () -> Unit,
searchAction: (searchInfo: SearchInfo) -> Unit, searchAction: (searchInfo: SearchInfo) -> Unit,
@@ -138,7 +233,9 @@ object EntrySelectionHelper {
keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit, keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit,
autofillSelectionAction: (searchInfo: SearchInfo?, autofillSelectionAction: (searchInfo: SearchInfo?,
autofillComponent: AutofillComponent) -> Unit, autofillComponent: AutofillComponent) -> Unit,
autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) { autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit,
passkeySelectionAction: (searchInfo: SearchInfo?) -> Unit,
passkeyRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) {
when (retrieveSpecialModeFromIntent(intent)) { when (retrieveSpecialModeFromIntent(intent)) {
SpecialMode.DEFAULT -> { SpecialMode.DEFAULT -> {
@@ -186,6 +283,7 @@ object EntrySelectionHelper {
defaultAction.invoke() defaultAction.invoke()
} }
TypeMode.MAGIKEYBOARD -> keyboardSelectionAction.invoke(searchInfo) TypeMode.MAGIKEYBOARD -> keyboardSelectionAction.invoke(searchInfo)
TypeMode.PASSKEY -> passkeySelectionAction.invoke(searchInfo)
else -> { else -> {
// In this case, error // In this case, error
removeModesFromIntent(intent) removeModesFromIntent(intent)
@@ -202,10 +300,59 @@ object EntrySelectionHelper {
} }
SpecialMode.REGISTRATION -> { SpecialMode.REGISTRATION -> {
val registerInfo: RegisterInfo? = retrieveRegisterInfoFromIntent(intent) val registerInfo: RegisterInfo? = retrieveRegisterInfoFromIntent(intent)
removeModesFromIntent(intent) if (!isIntentSenderMode(
removeInfoFromIntent(intent) specialMode = retrieveSpecialModeFromIntent(intent),
autofillRegistrationAction.invoke(registerInfo) typeMode = retrieveTypeModeFromIntent(intent))
) {
removeModesFromIntent(intent)
removeInfoFromIntent(intent)
}
when (retrieveTypeModeFromIntent(intent)) {
TypeMode.AUTOFILL -> {
autofillRegistrationAction.invoke(registerInfo)
}
TypeMode.PASSKEY -> {
passkeyRegistrationAction.invoke(registerInfo)
}
else -> {
// Do other registration type
}
}
} }
} }
} }
fun performSelection(items: List<EntryInfo>,
actionPopulateCredentialProvider: (entryInfo: EntryInfo) -> Unit,
actionEntrySelection: (autoSearch: Boolean) -> Unit) {
if (items.size == 1) {
val itemFound = items[0]
actionPopulateCredentialProvider.invoke(itemFound)
} else if (items.size > 1) {
// Select the one we want in the selection
actionEntrySelection.invoke(true)
} else {
// Select an arbitrary one
actionEntrySelection.invoke(false)
}
}
/**
* Method to assign a drawable to a new icon from a database icon
*/
@RequiresApi(Build.VERSION_CODES.M)
fun EntryInfo.buildIcon(
context: Context,
database: ContextualDatabase
): Icon? {
try {
database.iconDrawableFactory.getBitmapFromIcon(context,
this.icon, ContextCompat.getColor(context, R.color.green))?.let { bitmap ->
return Icon.createWithBitmap(bitmap)
}
} catch (e: Exception) {
Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e)
}
return null
}
} }

View File

@@ -1,4 +1,4 @@
package com.kunzisoft.keepass.activities.helpers package com.kunzisoft.keepass.credentialprovider
enum class SpecialMode { enum class SpecialMode {
DEFAULT, DEFAULT,

View File

@@ -0,0 +1,5 @@
package com.kunzisoft.keepass.credentialprovider
enum class TypeMode {
DEFAULT, MAGIKEYBOARD, AUTOFILL, PASSKEY
}

View File

@@ -17,7 +17,7 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>. * along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
package com.kunzisoft.keepass.activities package com.kunzisoft.keepass.credentialprovider.activity
import android.app.Activity import android.app.Activity
import android.app.PendingIntent import android.app.PendingIntent
@@ -30,13 +30,17 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity
import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.activities.GroupActivity
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.autofill.AutofillComponent import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.autofill.AutofillHelper import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.autofill.CompatInlineSuggestionsRequest import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.KeeAutofillService import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper
import com.kunzisoft.keepass.credentialprovider.autofill.CompatInlineSuggestionsRequest
import com.kunzisoft.keepass.credentialprovider.autofill.KeeAutofillService
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.RegisterInfo
@@ -48,10 +52,8 @@ import com.kunzisoft.keepass.utils.getParcelableExtraCompat
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
class AutofillLauncherActivity : DatabaseModeActivity() { class AutofillLauncherActivity : DatabaseModeActivity() {
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? = private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) this.buildActivityResultLauncher(lockDatabase = true)
AutofillHelper.buildActivityResultLauncher(this, true)
else null
override fun applyCustomStyle(): Boolean { override fun applyCustomStyle(): Boolean {
return false return false
@@ -72,7 +74,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
// To pass extra inline request // To pass extra inline request
var compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? = null var compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
compatInlineSuggestionsRequest = bundle.getParcelableCompat(KEY_INLINE_SUGGESTION) compatInlineSuggestionsRequest = bundle.getParcelableCompat(
KEY_INLINE_SUGGESTION
)
} }
// Build search param // Build search param
bundle.getParcelableCompat<SearchInfo>(KEY_SEARCH_INFO)?.let { searchInfo -> bundle.getParcelableCompat<SearchInfo>(KEY_SEARCH_INFO)?.let { searchInfo ->
@@ -102,7 +106,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
} }
SpecialMode.REGISTRATION -> { SpecialMode.REGISTRATION -> {
// To register info // To register info
val registerInfo = intent.getParcelableExtraCompat<RegisterInfo>(KEY_REGISTER_INFO) val registerInfo = intent.getParcelableExtraCompat<RegisterInfo>(
KEY_REGISTER_INFO
)
val searchInfo = SearchInfo(registerInfo?.searchInfo) val searchInfo = SearchInfo(registerInfo?.searchInfo)
WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain -> WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain ->
searchInfo.webDomain = concreteWebDomain searchInfo.webDomain = concreteWebDomain
@@ -134,30 +140,35 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
finish() finish()
} else { } else {
// If database is open // If database is open
SearchHelper.checkAutoSearchInfo(this, SearchHelper.checkAutoSearchInfo(
database, context = this,
searchInfo, database = database,
{ openedDatabase, items -> searchInfo = searchInfo,
// Items found onItemsFound = { openedDatabase, items ->
AutofillHelper.buildResponseAndSetResult(this, openedDatabase, items) // Items found
finish() AutofillHelper.buildResponseAndSetResult(this, openedDatabase, items)
}, finish()
{ openedDatabase -> },
// Show the database UI to select the entry onItemNotFound = { openedDatabase ->
GroupActivity.launchForAutofillResult(this, // Show the database UI to select the entry
openedDatabase, GroupActivity.launchForAutofillSelectionResult(
mAutofillActivityResultLauncher, this,
autofillComponent, openedDatabase,
searchInfo, mCredentialActivityResultLauncher,
false) autofillComponent,
}, searchInfo,
{ false
// If database not open )
FileDatabaseSelectActivity.launchForAutofillResult(this, },
mAutofillActivityResultLauncher, onDatabaseClosed = {
autofillComponent, // If database not open
searchInfo) FileDatabaseSelectActivity.launchForAutofillResult(
} this,
mCredentialActivityResultLauncher,
autofillComponent,
searchInfo
)
}
) )
} }
} }
@@ -174,34 +185,47 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
setResult(Activity.RESULT_CANCELED) setResult(Activity.RESULT_CANCELED)
} else { } else {
val readOnly = database?.isReadOnly != false val readOnly = database?.isReadOnly != false
SearchHelper.checkAutoSearchInfo(this, SearchHelper.checkAutoSearchInfo(
database, context = this,
searchInfo, database = database,
{ openedDatabase, _ -> searchInfo = searchInfo,
if (!readOnly) { onItemsFound = { openedDatabase, _ ->
// Show the database UI to select the entry if (!readOnly) {
GroupActivity.launchForRegistration(this, // Show the database UI to select the entry
openedDatabase, GroupActivity.launchForRegistration(
registerInfo) context = this,
} else { activityResultLauncher = null, // TODO Autofill result launcher #765
showReadOnlySaveMessage() database = openedDatabase,
} registerInfo = registerInfo,
}, typeMode = TypeMode.AUTOFILL
{ openedDatabase -> )
if (!readOnly) { } else {
// Show the database UI to select the entry showReadOnlySaveMessage()
GroupActivity.launchForRegistration(this,
openedDatabase,
registerInfo)
} else {
showReadOnlySaveMessage()
}
},
{
// If database not open
FileDatabaseSelectActivity.launchForRegistration(this,
registerInfo)
} }
},
onItemNotFound = { openedDatabase ->
if (!readOnly) {
// Show the database UI to select the entry
GroupActivity.launchForRegistration(
context = this,
activityResultLauncher = null, // TODO Autofill result launcher #765
database = openedDatabase,
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL
)
} else {
showReadOnlySaveMessage()
}
},
onDatabaseClosed = {
// If database not open
FileDatabaseSelectActivity.launchForRegistration(
context = this,
activityResultLauncher = null, // TODO Autofill result launcher #765
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL
)
}
) )
} }
finish() finish()

View File

@@ -1,281 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.activity
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import com.kunzisoft.asymmetric.Signature
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.legacy.DatabaseActivity
import com.kunzisoft.keepass.credentialprovider.data.Passkey
import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialCreationOptions
import com.kunzisoft.keepass.credentialprovider.util.AppRelyingPartyRelation
import com.kunzisoft.keepass.credentialprovider.util.Base64Helper
import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper
import com.kunzisoft.keepass.credentialprovider.util.IntentHelper
import com.kunzisoft.keepass.credentialprovider.util.JsonHelper
import com.kunzisoft.keepass.credentialprovider.util.OriginHelper
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.DatabaseTaskProvider
import com.kunzisoft.random.KeePassDXRandom
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class CreatePasskeyActivity : DatabaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Log.d(javaClass.simpleName, "onCreate called")
super.onCreate(savedInstanceState)
}
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
Log.d(javaClass.simpleName, "onDatabaseRetrieved called")
super.onDatabaseRetrieved(database)
try {
if (database == null) {
throw CreateCredentialUnknownException("retrievedDatabase is null, maybe database is locked")
}
if (intent == null) {
throw CreateCredentialUnknownException("intent is null")
}
if (mDatabaseTaskProvider == null) {
throw CreateCredentialUnknownException("mDatabaseTaskProvider is null")
}
createPasskeyAfterPrompt(database, mDatabaseTaskProvider!!, intent)
} catch (e: CreateCredentialUnknownException) {
Log.e(this::class.java.simpleName, "CreateCredentialUnknownException was thrown", e)
setResult(RESULT_CANCELED)
finish()
} catch (e: Exception) {
Log.e(this::class.java.simpleName, "other exception was thrown", e)
setResult(RESULT_CANCELED)
finish()
}
}
private fun createPasskeyAfterPrompt(
database: ContextualDatabase,
databaseTaskProvider: DatabaseTaskProvider,
intent: Intent
) {
val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
?: throw CreateCredentialUnknownException("could not retrieve request from intent")
if (request.callingRequest !is CreatePublicKeyCredentialRequest) {
throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}")
}
val publicKeyRequest = request.callingRequest as CreatePublicKeyCredentialRequest
val creationOptions = JsonHelper.parseJsonToCreateOptions(publicKeyRequest.requestJson)
val relyingParty = creationOptions.relyingParty
val biometricPrompt = BiometricPrompt(
this,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int, errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString)
throw CreateCredentialUnknownException("authentication error: errorCode = $errorCode, errString = $errString")
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
throw CreateCredentialUnknownException("authentication failed")
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
createPasskey(database, databaseTaskProvider, intent, creationOptions)
}
}
)
val title = getString(R.string.passkey_creation_biometric_prompt_title)
val subtitle =
getString(
R.string.passkey_creation_biometric_prompt_subtitle,
relyingParty
)
val negativeButtonText =
getString(R.string.passkey_creation_biometric_prompt_negative_button_text)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setNegativeButtonText(negativeButtonText)
.build()
biometricPrompt.authenticate(promptInfo)
}
private fun createPasskey(
database: ContextualDatabase,
databaseTaskProvider: DatabaseTaskProvider,
intent: Intent,
creationOptions: PublicKeyCredentialCreationOptions
) {
val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
val nodeId = IntentHelper.getVerifiedNodeId(intent)
?: throw CreateCredentialUnknownException("could not get verified nodeId from intent")
val callingAppInfo = request!!.callingAppInfo
val relyingParty = creationOptions.relyingParty
val challenge = creationOptions.challenge
val keyTypeIdList = creationOptions.keyTypeIdList
val webOrigin = OriginHelper.getWebOrigin(callingAppInfo, assets)
val apkSigningCertificate =
callingAppInfo.signingInfo.apkContentsSigners.getOrNull(0)?.toByteArray()
createPasskeyWithParameters(
relyingParty,
creationOptions.username,
creationOptions.userId,
database,
databaseTaskProvider,
keyTypeIdList,
challenge,
webOrigin,
apkSigningCertificate,
nodeId
)
}
private fun createPasskeyWithParameters(
relyingParty: String,
username: String,
userHandle: ByteArray,
database: ContextualDatabase,
databaseTaskProvider: DatabaseTaskProvider,
keyTypeIdList: List<Long>,
challenge: ByteArray,
webOrigin: String?,
apkSigningCertificate: ByteArray?,
nodeId: String
) {
val isPrivilegedApp =
(webOrigin != null && webOrigin == OriginHelper.DEFAULT_PROTOCOL + relyingParty)
Log.d(this::class.java.simpleName, "isPrivilegedApp = $isPrivilegedApp")
if (!isPrivilegedApp) {
val isValid =
AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate)
if (!isValid) {
throw CreateCredentialUnknownException(
"could not verify relation between app " +
"and relyingParty $relyingParty"
)
}
}
val credentialId = KeePassDXRandom.generateCredentialId()
val (keyPair, keyTypeId) = Signature.generateKeyPair(keyTypeIdList)
?: throw CreateCredentialUnknownException("no known public key type found")
val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private)
if (IntentHelper.isPlaceholderNodeId(nodeId)) {
// create new entry in database
val displayName = "$relyingParty (Passkey)"
val newPasskey = Passkey(
nodeId = "", // created by the database
username = username,
displayName = displayName,
privateKeyPem = privateKeyPem,
credId = Base64Helper.b64Encode(credentialId),
userHandle = Base64Helper.b64Encode(userHandle),
relyingParty = relyingParty,
databaseEntry = null
)
DatabaseHelper.saveNewEntry(database, databaseTaskProvider, newPasskey)
} else {
// update an existing entry in database
val oldPasskey = DatabaseHelper.searchPassKeyByNodeId(database, nodeId)
?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found")
val updatedPasskey = Passkey(
nodeId = "", // unchanged
username = username,
displayName = oldPasskey.displayName,
privateKeyPem = privateKeyPem,
credId = Base64Helper.b64Encode(credentialId),
userHandle = Base64Helper.b64Encode(userHandle),
relyingParty = relyingParty,
databaseEntry = oldPasskey.databaseEntry
)
DatabaseHelper.updateEntry(database, databaseTaskProvider, updatedPasskey)
}
val publicKeyEncoded = Signature.convertPublicKey(keyPair.public, keyTypeId)
val publicKeyMap = Signature.convertPublicKeyToMap(keyPair.public, keyTypeId)
val publicKeyCbor = JsonHelper.generateCborFromMap(publicKeyMap!!)
val authData = JsonHelper.generateAuthDataForCreate(
userPresent = true,
userVerified = true,
backupEligibility = true,
backupState = true,
rpId = relyingParty.toByteArray(),
credentialId = credentialId,
credentialPublicKey = publicKeyCbor
)
val attestationObject = JsonHelper.generateAttestationObject(authData)
val clientJson: String
if (isPrivilegedApp) {
clientJson = JsonHelper.generateClientDataJsonPrivileged()
} else {
val origin = OriginHelper.DEFAULT_PROTOCOL + relyingParty
clientJson = JsonHelper.generateClientDataJsonNonPrivileged(
challenge,
origin,
packageName,
isCrossOriginAdded = true,
isGet = false
)
}
val responseJson = JsonHelper.createAuthenticatorAttestationResponseJSON(
credentialId,
clientJson,
attestationObject,
publicKeyEncoded!!,
authData,
keyTypeId
)
// log only the length to prevent logging sensitive information
Log.d(javaClass.simpleName, "responseJson with length ${responseJson.length} created")
val createPublicKeyCredResponse = CreatePublicKeyCredentialResponse(responseJson)
val resultOfActivity = Intent()
PendingIntentHandler.setCreateCredentialResponse(
resultOfActivity, createPublicKeyCredResponse
)
setResult(Activity.RESULT_OK, resultOfActivity)
finish()
}
}

View File

@@ -17,23 +17,25 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>. * along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
package com.kunzisoft.keepass.activities package com.kunzisoft.keepass.credentialprovider.activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.core.net.toUri
import com.kunzisoft.keepass.R 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.DatabaseModeActivity
import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.KeyboardUtil.isKeyboardActivatedInSettings import com.kunzisoft.keepass.utils.KeyboardUtil.isKeyboardActivatedInSettings
import com.kunzisoft.keepass.utils.getParcelableCompat
import com.kunzisoft.keepass.utils.WebDomain import com.kunzisoft.keepass.utils.WebDomain
import com.kunzisoft.keepass.utils.getParcelableCompat
/** /**
* Activity to search or select entry in database, * Activity to search or select entry in database,
@@ -73,7 +75,7 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() {
if (OtpEntryFields.isOTPUri(extra)) if (OtpEntryFields.isOTPUri(extra))
otpString = extra otpString = extra
else else
sharedWebDomain = Uri.parse(extra).host sharedWebDomain = extra.toUri().host
} }
} }
launchSelection(database, sharedWebDomain, otpString) launchSelection(database, sharedWebDomain, otpString)
@@ -121,87 +123,105 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() {
// If database is open // If database is open
val readOnly = database?.isReadOnly != false val readOnly = database?.isReadOnly != false
SearchHelper.checkAutoSearchInfo(this, SearchHelper.checkAutoSearchInfo(
database, context = this,
searchInfo, database = database,
{ openedDatabase, items -> searchInfo = searchInfo,
// Items found onItemsFound = { openedDatabase, items ->
if (searchInfo.otpString != null) { // Items found
if (!readOnly) { if (searchInfo.otpString != null) {
GroupActivity.launchForSaveResult( if (!readOnly) {
GroupActivity.launchForSaveResult(
this,
openedDatabase,
searchInfo,
false
)
} else {
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
}
} else if (searchShareForMagikeyboard) {
MagikeyboardService.performSelection(
items,
{ entryInfo ->
// Automatically populate keyboard
MagikeyboardService.populateKeyboardAndMoveAppToBackground(
this,
entryInfo
)
},
{ autoSearch ->
GroupActivity.launchForKeyboardSelectionResult(
this, this,
openedDatabase, openedDatabase,
searchInfo, searchInfo,
false) autoSearch
} else { )
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
} }
} else if (searchShareForMagikeyboard) { )
MagikeyboardService.performSelection( } else {
items, GroupActivity.launchForSearchResult(
{ entryInfo -> this,
// Automatically populate keyboard openedDatabase,
MagikeyboardService.populateKeyboardAndMoveAppToBackground( searchInfo,
this, true
entryInfo )
) }
}, },
{ autoSearch -> onItemNotFound = { openedDatabase ->
GroupActivity.launchForKeyboardSelectionResult(this, // Show the database UI to select the entry
openedDatabase, if (searchInfo.otpString != null) {
searchInfo, if (!readOnly) {
autoSearch) GroupActivity.launchForSaveResult(
} this,
openedDatabase,
searchInfo,
false
) )
} else { } else {
GroupActivity.launchForSearchResult(this, Toast.makeText(applicationContext,
openedDatabase, R.string.autofill_read_only_save,
searchInfo, Toast.LENGTH_LONG)
true) .show()
}
},
{ openedDatabase ->
// Show the database UI to select the entry
if (searchInfo.otpString != null) {
if (!readOnly) {
GroupActivity.launchForSaveResult(this,
openedDatabase,
searchInfo,
false)
} else {
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
Toast.LENGTH_LONG)
.show()
}
} else if (searchShareForMagikeyboard) {
GroupActivity.launchForKeyboardSelectionResult(this,
openedDatabase,
searchInfo,
false)
} else {
GroupActivity.launchForSearchResult(this,
openedDatabase,
searchInfo,
false)
}
},
{
// If database not open
if (searchInfo.otpString != null) {
FileDatabaseSelectActivity.launchForSaveResult(this,
searchInfo)
} else if (searchShareForMagikeyboard) {
FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this,
searchInfo)
} else {
FileDatabaseSelectActivity.launchForSearchResult(this,
searchInfo)
} }
} else if (searchShareForMagikeyboard) {
GroupActivity.launchForKeyboardSelectionResult(
this,
openedDatabase,
searchInfo,
false
)
} else {
GroupActivity.launchForSearchResult(
this,
openedDatabase,
searchInfo,
false
)
} }
},
onDatabaseClosed = {
// If database not open
if (searchInfo.otpString != null) {
FileDatabaseSelectActivity.launchForSaveResult(
this,
searchInfo
)
} else if (searchShareForMagikeyboard) {
FileDatabaseSelectActivity.launchForKeyboardSelectionResult(
this,
searchInfo
)
} else {
FileDatabaseSelectActivity.launchForSearchResult(
this,
searchInfo
)
}
}
) )
} }

View File

@@ -0,0 +1,348 @@
/*
* Copyright 2025 Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
* KeePassDX is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* KeePassDX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.credentialprovider.activity
import android.app.Activity
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.RequiresApi
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
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
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginHelper
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode
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.removePasskey
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.retrievePasskeyCreationComponent
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters
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.EntryInfoPasskey.getPasskey
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.utils.getParcelableExtraCompat
import java.util.UUID
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeyLauncherActivity : DatabaseModeActivity() {
private var mUsageParameters: PublicKeyCredentialUsageParameters? = null
private var mCreationParameters: PublicKeyCredentialCreationParameters? = null
private var mPasskey: Passkey? = null
private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher(
lockDatabase = true,
dataTransformation = { intent ->
Log.d(TAG, "Passkey selection result")
val passkey = intent?.retrievePasskey()
intent?.removePasskey()
// Build a new formatted response from the selection response
val responseIntent = Intent()
passkey?.let {
mUsageParameters?.let { usageParameters ->
PendingIntentHandler.setGetCredentialResponse(
responseIntent,
GetCredentialResponse(
buildPasskeyPublicKeyCredential(
usageParameters = usageParameters,
passkey = passkey
)
)
)
} ?: run {
Log.e(TAG, "Unable to return passkey, usage parameters are empty")
}
} ?: run {
Log.e(TAG, "Unable to get the passkey for response")
}
// Return the response
responseIntent
}
)
private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher(
lockDatabase = true,
dataTransformation = { intent ->
Log.d(TAG, "Passkey registration result")
val passkey = intent?.retrievePasskey()
intent?.removePasskey()
// Build a new formatted response from the creation response
val responseIntent = Intent()
// If registered passkey is the same as the one we want to validate,
if (mPasskey == passkey) {
mCreationParameters?.let {
PendingIntentHandler.setCreateCredentialResponse(
intent = responseIntent,
response = buildCreatePublicKeyCredentialResponse(
packageName = packageName,
publicKeyCredentialCreationParameters = it
)
)
}
}
responseIntent
}
)
override fun applyCustomStyle(): Boolean {
return false
}
override fun finishActivityIfReloadRequested(): Boolean {
return false
}
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
super.onDatabaseRetrieved(database)
// TODO nodeId Really useful ? checkSecurity(intent, nodeId)
EntrySelectionHelper.retrieveSpecialModeFromIntent(intent).let { specialMode ->
val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo()
when (specialMode) {
SpecialMode.SELECTION -> {
launchSelection(database, searchInfo)
}
SpecialMode.REGISTRATION -> {
launchRegistration(database, searchInfo)
}
else -> {
Log.e(TAG, "Passkey launch mode not supported")
setResult(Activity.RESULT_CANCELED)
finish()
}
}
}
}
private fun autoSelectPasskeyAndSetResult(
database: ContextualDatabase?,
nodeId: UUID
) {
mUsageParameters?.let { usageParameters ->
// To get the passkey from the database
val passkey = database
?.getEntryById(NodeIdUUID(nodeId))
?.getEntryInfo(database)
?.getPasskey()
?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found")
val result = Intent()
PendingIntentHandler.setGetCredentialResponse(
result,
GetCredentialResponse(
buildPasskeyPublicKeyCredential(
usageParameters = usageParameters,
passkey = passkey
)
)
)
setResult(RESULT_OK, result)
finish()
} ?: run {
Log.e(TAG, "Unable to auto select passkey, usage parameters are empty")
setResult(Activity.RESULT_CANCELED)
finish()
}
}
private fun launchSelection(
database: ContextualDatabase?,
searchInfo: SearchInfo?
) {
Log.d(TAG, "Launch passkey selection")
retrievePasskeyUsageRequestParameters(this@PasskeyLauncherActivity, intent) { usageParameters ->
// Save the requested parameters
mUsageParameters = usageParameters
// Manage the passkey to use
intent.retrieveNodeId()?.let { nodeId ->
autoSelectPasskeyAndSetResult(database, nodeId)
} ?: 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 = true
)
},
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
) {
// TODO Overwrite automatic selection
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(Activity.RESULT_CANCELED)
finish()
}
}
private fun launchRegistration(
database: ContextualDatabase?,
searchInfo: SearchInfo
) {
Log.d(TAG, "Launch passkey registration")
PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.callingAppInfo?.let { callingAppInfo ->
retrievePasskeyCreationRequestParameters(
creationOptions = intent.retrievePasskeyCreationComponent(),
webOrigin = OriginHelper.getWebOrigin(callingAppInfo, assets),
apkSigningCertificate =
callingAppInfo
.signingInfo.apkContentsSigners
.getOrNull(0)?.toByteArray(),
passkeyCreated = { passkey, publicKeyCredentialParameters ->
// Save the requested parameters
mPasskey = passkey
mCreationParameters = publicKeyCredentialParameters
// Manage the passkey and create a register info
val registerInfo = RegisterInfo(
searchInfo = searchInfo,
username = null,
passkey = passkey
)
// If nodeId already provided
intent.retrieveNodeId()?.let { nodeId ->
autoRegisterPasskeyAndSetResult(database, nodeId)
} ?: run {
SearchHelper.checkAutoSearchInfo(
context = this,
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, _ ->
Log.w(TAG, "Passkey found for registration, " +
"but launch manual registration for overwrite")
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
)
}
)
}
}
)
}
}
companion object {
private val TAG = PasskeyLauncherActivity::class.java.name
private const val EXTRA_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO"
fun Intent.retrieveSearchInfo(): SearchInfo? {
return this.getParcelableExtraCompat(EXTRA_SEARCH_INFO)
}
fun Intent.removeSearchInfo() {
return this.removeExtra(EXTRA_SEARCH_INFO)
}
fun getPendingIntent(
context: Context,
specialMode: SpecialMode,
searchInfo: SearchInfo? = null,
passkeyEntryNodeId: UUID? = null
): PendingIntent? {
return PendingIntent.getActivity(
context,
Math.random().toInt(),
Intent(context, PasskeyLauncherActivity::class.java).apply {
addSpecialMode(specialMode)
searchInfo?.let {
putExtra(EXTRA_SEARCH_INFO, searchInfo)
}
addAuthCode(passkeyEntryNodeId)
},
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
)
}
}
}

View File

@@ -1,236 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.activity
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.credentials.GetCredentialResponse
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import com.kunzisoft.asymmetric.Signature
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.legacy.DatabaseActivity
import com.kunzisoft.keepass.credentialprovider.data.Passkey
import com.kunzisoft.keepass.credentialprovider.util.AppRelyingPartyRelation
import com.kunzisoft.keepass.credentialprovider.util.Base64Helper
import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper
import com.kunzisoft.keepass.credentialprovider.util.IntentHelper
import com.kunzisoft.keepass.credentialprovider.util.JsonHelper
import com.kunzisoft.keepass.credentialprovider.util.OriginHelper
import com.kunzisoft.keepass.credentialprovider.util.OriginHelper.Companion.DEFAULT_PROTOCOL
import com.kunzisoft.keepass.database.ContextualDatabase
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class UsePasskeyActivity : DatabaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Log.d(javaClass.simpleName, "onCreate called")
super.onCreate(savedInstanceState)
}
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
Log.d(javaClass.simpleName, "onDatabaseRetrieved called")
super.onDatabaseRetrieved(database)
try {
if (database == null) {
throw CreateCredentialUnknownException("retrievedDatabase is null, maybe database is locked")
}
if (intent == null) {
throw CreateCredentialUnknownException("intent is null")
}
usePasskeyAfterPrompt(database, intent)
} catch (e: CreateCredentialUnknownException) {
Log.e(this::class.java.simpleName, "CreateCredentialUnknownException was thrown", e)
setResult(RESULT_CANCELED)
finish()
} catch (e: Exception) {
Log.e(this::class.java.simpleName, "other exception was thrown", e)
setResult(RESULT_CANCELED)
finish()
}
}
private fun usePasskeyAfterPrompt(
database: ContextualDatabase,
intent: Intent
) {
val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
?: throw CreateCredentialUnknownException("could not retrieve request from intent")
if (request.credentialOptions.size != 1) {
throw GetCredentialUnknownException("not exact one credentialOption")
}
if (request.credentialOptions[0] !is GetPublicKeyCredentialOption) {
throw CreateCredentialUnknownException("credentialOptions is of wrong type: ${request.credentialOptions[0]}")
}
val credentialOption = request.credentialOptions[0] as GetPublicKeyCredentialOption
val clientDataHash = credentialOption.clientDataHash
val requestOptions = JsonHelper.parseJsonToRequestOptions(credentialOption.requestJson)
val relyingParty = requestOptions.relyingParty
val challenge = Base64Helper.b64Decode(requestOptions.challengeString)
val packageName = request.callingAppInfo.packageName
val webOrigin = OriginHelper.getWebOrigin(request.callingAppInfo, assets)
val isPrivilegedApp =
(webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty && clientDataHash != null)
Log.d(javaClass.simpleName, "isPrivilegedApp = $isPrivilegedApp")
if (!isPrivilegedApp) {
val apkSigners = request.callingAppInfo.signingInfo.apkContentsSigners
val apkSigningCertificate = apkSigners.getOrNull(0)?.toByteArray()
val isValid =
AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate)
if (!isValid) {
throw CreateCredentialUnknownException(
"could not verify relation between app " +
"and relyingParty $relyingParty"
)
}
}
val nodeId = IntentHelper.getVerifiedNodeId(intent)
?: throw GetCredentialUnknownException("could not get verified nodeId from intent")
val passkey = DatabaseHelper.searchPassKeyByNodeId(database, nodeId)
?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found")
usePasskeyAfterPromptWithParameters(
relyingParty,
packageName,
clientDataHash,
isPrivilegedApp,
challenge,
passkey
)
}
private fun usePasskeyAfterPromptWithParameters(
relyingParty: String,
packageName: String,
clientDataHash: ByteArray?,
isPrivilegedApp: Boolean,
challenge: ByteArray,
passkey: Passkey
) {
val biometricPrompt = BiometricPrompt(
this,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
throw GetCredentialUnknownException("authentication error: errorCode = $errorCode, errString = $errString")
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
throw GetCredentialUnknownException("authentication failed")
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
createResponse(
relyingParty,
packageName,
clientDataHash,
isPrivilegedApp,
challenge,
passkey
)
}
}
)
val title = getString(R.string.passkey_usage_biometric_prompt_title)
val subtitle = getString(R.string.passkey_usage_biometric_prompt_subtitle, relyingParty)
val negativeButtonText =
getString(R.string.passkey_usage_biometric_prompt_negative_button_text)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setNegativeButtonText(negativeButtonText)
.build()
biometricPrompt.authenticate(promptInfo)
}
private fun createResponse(
relyingParty: String,
packageName: String,
clientDataHash: ByteArray?,
isPrivilegedApp: Boolean,
challenge: ByteArray,
passkey: Passkey
) {
// https://www.w3.org/TR/webauthn-3/#authdata-flags
val userPresent = true
val userVerified = true
val backupEligibility = true
val backupState = true
val authenticatorData = JsonHelper.generateAuthDataForUsage(
relyingParty.toByteArray(),
userPresent,
userVerified,
backupEligibility,
backupState
)
val clientDataJson: String
val dataToSign: ByteArray
if (isPrivilegedApp) {
clientDataJson = JsonHelper.generateClientDataJsonPrivileged()
dataToSign =
JsonHelper.generateDataToSignPrivileged(clientDataHash!!, authenticatorData)
} else {
val origin = DEFAULT_PROTOCOL + relyingParty
clientDataJson = JsonHelper.generateClientDataJsonNonPrivileged(
challenge,
origin,
packageName,
isGet = true,
isCrossOriginAdded = false
)
dataToSign =
JsonHelper.generateDataTosSignNonPrivileged(clientDataJson, authenticatorData)
}
val signature = Signature.sign(passkey.privateKeyPem, dataToSign)
?: throw GetCredentialUnknownException("signing failed")
val getCredentialResponse =
JsonHelper.generateGetCredentialResponse(
clientDataJson.toByteArray(),
authenticatorData,
signature,
passkey.userHandle,
passkey.credId
)
val result = Intent()
val passkeyCredential = PublicKeyCredential(getCredentialResponse)
PendingIntentHandler.setGetCredentialResponse(
result, GetCredentialResponse(passkeyCredential)
)
setResult(RESULT_OK, result)
finish()
}
}

View File

@@ -1,4 +1,4 @@
package com.kunzisoft.keepass.autofill package com.kunzisoft.keepass.credentialprovider.autofill
import android.app.assist.AssistStructure import android.app.assist.AssistStructure

View File

@@ -17,7 +17,7 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>. * along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
package com.kunzisoft.keepass.autofill package com.kunzisoft.keepass.credentialprovider.autofill
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
@@ -40,17 +40,13 @@ import android.view.autofill.AutofillValue
import android.widget.RemoteViews import android.widget.RemoteViews
import android.widget.Toast import android.widget.Toast
import android.widget.inline.InlinePresentationSpec import android.widget.inline.InlinePresentationSpec
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.autofill.inline.UiVersions import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.autofill.inline.v1.InlineSuggestionUi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.AutofillLauncherActivity import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity
import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.database.element.icon.IconImage
import com.kunzisoft.keepass.database.element.template.TemplateField import com.kunzisoft.keepass.database.element.template.TemplateField
@@ -58,7 +54,6 @@ import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.settings.AutofillSettingsActivity import com.kunzisoft.keepass.settings.AutofillSettingsActivity
import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.LOCK_ACTION
import com.kunzisoft.keepass.utils.getParcelableExtraCompat import com.kunzisoft.keepass.utils.getParcelableExtraCompat
import kotlin.math.min import kotlin.math.min
@@ -294,23 +289,6 @@ object AutofillHelper {
return dataset return dataset
} }
/**
* Method to assign a drawable to a new icon from a database icon
*/
private fun buildIconFromEntry(context: Context,
database: ContextualDatabase,
entryInfo: EntryInfo): Icon? {
try {
database.iconDrawableFactory.getBitmapFromIcon(context,
entryInfo.icon, ContextCompat.getColor(context, R.color.green))?.let { bitmap ->
return Icon.createWithBitmap(bitmap)
}
} catch (e: Exception) {
Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e)
}
return null
}
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
private fun buildInlinePresentationForEntry(context: Context, private fun buildInlinePresentationForEntry(context: Context,
@@ -353,7 +331,7 @@ object AutofillHelper {
Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply { Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply {
setTintBlendMode(BlendMode.DST) setTintBlendMode(BlendMode.DST)
}) })
buildIconFromEntry(context, database, entryInfo)?.let { icon -> entryInfo.buildIcon(context, database)?.let { icon ->
setEndIcon(icon.apply { setEndIcon(icon.apply {
setTintBlendMode(BlendMode.DST) setTintBlendMode(BlendMode.DST)
}) })
@@ -534,7 +512,9 @@ object AutofillHelper {
StructureParser(structure).parse()?.let { result -> StructureParser(structure).parse()?.let { result ->
// New Response // New Response
val response = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val response = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val compatInlineSuggestionsRequest = activity.intent?.getParcelableExtraCompat<CompatInlineSuggestionsRequest>(EXTRA_INLINE_SUGGESTIONS_REQUEST) val compatInlineSuggestionsRequest = activity.intent?.getParcelableExtraCompat<CompatInlineSuggestionsRequest>(
EXTRA_INLINE_SUGGESTIONS_REQUEST
)
if (compatInlineSuggestionsRequest != null) { if (compatInlineSuggestionsRequest != null) {
Toast.makeText(activity.applicationContext, R.string.autofill_inline_suggestions_keyboard, Toast.LENGTH_SHORT).show() Toast.makeText(activity.applicationContext, R.string.autofill_inline_suggestions_keyboard, Toast.LENGTH_SHORT).show()
} }
@@ -558,45 +538,14 @@ object AutofillHelper {
} }
} }
fun buildActivityResultLauncher(activity: AppCompatActivity, fun Intent.addAutofillComponent(context: Context, autofillComponent: AutofillComponent) {
lockDatabase: Boolean = false): ActivityResultLauncher<Intent> { this.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure)
return activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
// Utility method to loop and close each activity with return data
if (it.resultCode == Activity.RESULT_OK) {
activity.setResult(it.resultCode, it.data)
}
if (it.resultCode == Activity.RESULT_CANCELED) {
activity.setResult(Activity.RESULT_CANCELED)
}
activity.finish()
if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(activity)) {
// Close the database
activity.sendBroadcast(Intent(LOCK_ACTION))
}
}
}
/**
* Utility method to start an activity with an Autofill for result
*/
fun startActivityForAutofillResult(activity: AppCompatActivity,
intent: Intent,
activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent,
searchInfo: SearchInfo?) {
EntrySelectionHelper.addSpecialModeInIntent(intent, SpecialMode.SELECTION)
intent.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& PreferencesUtil.isAutofillInlineSuggestionsEnable(activity)) { && PreferencesUtil.isAutofillInlineSuggestionsEnable(context)) {
autofillComponent.compatInlineSuggestionsRequest?.let { autofillComponent.compatInlineSuggestionsRequest?.let {
intent.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it) this.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it)
} }
} }
EntrySelectionHelper.addSearchInfoInIntent(intent, searchInfo)
activityResultLauncher?.launch(intent)
} }
private val TAG = AutofillHelper::class.java.name private val TAG = AutofillHelper::class.java.name

View File

@@ -17,7 +17,7 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>. * along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
package com.kunzisoft.keepass.autofill package com.kunzisoft.keepass.credentialprovider.autofill
import android.annotation.TargetApi import android.annotation.TargetApi
import android.os.Build import android.os.Build

View File

@@ -17,7 +17,7 @@
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>. * along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
package com.kunzisoft.keepass.autofill package com.kunzisoft.keepass.credentialprovider.autofill
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
@@ -43,8 +43,8 @@ import androidx.annotation.RequiresApi
import androidx.autofill.inline.UiVersions import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.autofill.inline.v1.InlineSuggestionUi
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.AutofillLauncherActivity import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity
import com.kunzisoft.keepass.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW import com.kunzisoft.keepass.credentialprovider.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.DatabaseTaskProvider
import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.database.helper.SearchHelper
@@ -143,25 +143,28 @@ class KeeAutofillService : AutofillService() {
parseResult: StructureParser.Result, parseResult: StructureParser.Result,
inlineSuggestionsRequest: CompatInlineSuggestionsRequest?, inlineSuggestionsRequest: CompatInlineSuggestionsRequest?,
callback: FillCallback) { callback: FillCallback) {
SearchHelper.checkAutoSearchInfo(this, SearchHelper.checkAutoSearchInfo(
database, context = this,
searchInfo, database = database,
{ openedDatabase, items -> searchInfo = searchInfo,
callback.onSuccess( onItemsFound = { openedDatabase, items ->
AutofillHelper.buildResponse(this, openedDatabase, callback.onSuccess(
items, parseResult, inlineSuggestionsRequest) AutofillHelper.buildResponse(
this, openedDatabase,
items, parseResult, inlineSuggestionsRequest
) )
}, )
{ openedDatabase -> },
// Show UI if no search result onItemNotFound = { openedDatabase ->
showUIForEntrySelection(parseResult, openedDatabase, // Show UI if no search result
searchInfo, inlineSuggestionsRequest, callback) showUIForEntrySelection(parseResult, openedDatabase,
}, searchInfo, inlineSuggestionsRequest, callback)
{ },
// Show UI if database not open onDatabaseClosed = {
showUIForEntrySelection(parseResult, null, // Show UI if database not open
searchInfo, inlineSuggestionsRequest, callback) showUIForEntrySelection(parseResult, null,
} searchInfo, inlineSuggestionsRequest, callback)
}
) )
} }
@@ -385,19 +388,21 @@ class KeeAutofillService : AutofillService() {
// Show UI to save data // Show UI to save data
val registerInfo = RegisterInfo( val registerInfo = RegisterInfo(
SearchInfo().apply { searchInfo = SearchInfo().apply {
applicationId = parseResult.applicationId applicationId = parseResult.applicationId
webDomain = parseResult.webDomain webDomain = parseResult.webDomain
webScheme = parseResult.webScheme webScheme = parseResult.webScheme
}, },
parseResult.usernameValue?.textValue?.toString(), username = parseResult.usernameValue?.textValue?.toString(),
parseResult.passwordValue?.textValue?.toString(), password = parseResult.passwordValue?.textValue?.toString(),
creditCard =
CreditCard( CreditCard(
parseResult.creditCardHolder, parseResult.creditCardHolder,
parseResult.creditCardNumber, parseResult.creditCardNumber,
expiration, expiration,
parseResult.cardVerificationValue parseResult.cardVerificationValue
)) )
)
// TODO Callback in each activity #765 // TODO Callback in each activity #765
//if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {

View File

@@ -16,7 +16,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>. * along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*/ */
package com.kunzisoft.keepass.autofill package com.kunzisoft.keepass.credentialprovider.autofill
import android.app.assist.AssistStructure import android.app.assist.AssistStructure
import android.os.Build import android.os.Build

View File

@@ -1,14 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.data
import com.kunzisoft.keepass.database.element.Entry
data class Passkey(
val nodeId: String,
val username: String,
val displayName: String,
val privateKeyPem: String,
val credId: String,
val userHandle: String,
val relyingParty: String,
val databaseEntry: Entry?
)

View File

@@ -1,9 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.data
data class PublicKeyCredentialCreationOptions(
val relyingParty: String,
val challenge: ByteArray,
val username: String,
val userId: ByteArray,
val keyTypeIdList: List<Long>
)

View File

@@ -14,7 +14,7 @@
* the License. * the License.
*/ */
package com.kunzisoft.keepass.magikeyboard; package com.kunzisoft.keepass.credentialprovider.magikeyboard;
import android.content.Context; import android.content.Context;
import android.content.res.Resources; import android.content.res.Resources;

View File

@@ -14,14 +14,14 @@
* the License. * the License.
*/ */
package com.kunzisoft.keepass.magikeyboard; package com.kunzisoft.keepass.credentialprovider.magikeyboard;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_BACK_KEYBOARD; import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_BACK_KEYBOARD;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD; import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY; import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT; import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP; import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP_ALT; import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP_ALT;
import android.content.Context; import android.content.Context;
import android.content.res.TypedArray; import android.content.res.TypedArray;
@@ -52,7 +52,7 @@ import android.widget.TextView;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import com.kunzisoft.keepass.R; import com.kunzisoft.keepass.R;
import com.kunzisoft.keepass.magikeyboard.Keyboard.Key; import com.kunzisoft.keepass.credentialprovider.magikeyboard.Keyboard.Key;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;

View File

@@ -18,7 +18,7 @@
* *
*/ */
package com.kunzisoft.keepass.magikeyboard package com.kunzisoft.keepass.credentialprovider.magikeyboard
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
@@ -41,8 +41,8 @@ import androidx.core.graphics.BlendModeCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.EntrySelectionLauncherActivity import com.kunzisoft.keepass.credentialprovider.activity.EntrySelectionLauncherActivity
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.adapters.FieldsAdapter import com.kunzisoft.keepass.adapters.FieldsAdapter
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.DatabaseTaskProvider
@@ -341,10 +341,11 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL
} }
private fun actionKeyEntry(searchInfo: SearchInfo? = null) { private fun actionKeyEntry(searchInfo: SearchInfo? = null) {
SearchHelper.checkAutoSearchInfo(this, SearchHelper.checkAutoSearchInfo(
mDatabase, context = this,
searchInfo, database = mDatabase,
{ _, items -> searchInfo = searchInfo,
onItemsFound = { _, items ->
performSelection( performSelection(
items, items,
{ {
@@ -361,11 +362,11 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL
} }
) )
}, },
{ onItemNotFound = {
// Select if not found // Select if not found
launchEntrySelection(searchInfo) launchEntrySelection(searchInfo)
}, },
{ onDatabaseClosed = {
// Select if database not opened // Select if database not opened
removeEntryInfo() removeEntryInfo()
launchEntrySelection(searchInfo) launchEntrySelection(searchInfo)
@@ -463,21 +464,18 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL
fun performSelection(items: List<EntryInfo>, fun performSelection(items: List<EntryInfo>,
actionPopulateKeyboard: (entryInfo: EntryInfo) -> Unit, actionPopulateKeyboard: (entryInfo: EntryInfo) -> Unit,
actionEntrySelection: (autoSearch: Boolean) -> Unit) { actionEntrySelection: (autoSearch: Boolean) -> Unit) {
if (items.size == 1) { EntrySelectionHelper.performSelection(
val itemFound = items[0] items = items,
if (entryUUID != itemFound.id) { actionPopulateCredentialProvider = { itemFound ->
actionPopulateKeyboard.invoke(itemFound) if (entryUUID != itemFound.id) {
} else { actionPopulateKeyboard.invoke(itemFound)
// Force selection if magikeyboard already populated } else {
actionEntrySelection.invoke(false) // Force selection if magikeyboard already populated
} actionEntrySelection.invoke(false)
} else if (items.size > 1) { }
// Select the one we want in the selection },
actionEntrySelection.invoke(true) actionEntrySelection = actionEntrySelection
} else { )
// Select an arbitrary one
actionEntrySelection.invoke(false)
}
} }
fun populateKeyboardAndMoveAppToBackground(activity: Activity, fun populateKeyboardAndMoveAppToBackground(activity: Activity,

View File

@@ -0,0 +1,320 @@
package com.kunzisoft.keepass.credentialprovider.passkey
import android.graphics.BlendMode
import android.graphics.drawable.Icon
import android.os.Build
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.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
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.CredentialEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity
import com.kunzisoft.keepass.credentialprovider.passkey.util.JsonHelper
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.DatabaseTaskProvider
import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey
import com.kunzisoft.keepass.model.SearchInfo
import java.time.Instant
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeyProviderService : CredentialProviderService() {
private var mDatabaseTaskProvider: DatabaseTaskProvider? = null
private var mDatabase: ContextualDatabase? = null
private lateinit var defaultIcon: Icon
override fun onCreate() {
super.onCreate()
mDatabaseTaskProvider = DatabaseTaskProvider(this)
mDatabaseTaskProvider?.registerProgressTask()
mDatabaseTaskProvider?.onDatabaseRetrieved = { database ->
this.mDatabase = database
}
defaultIcon = Icon.createWithResource(
this@PasskeyProviderService,
R.mipmap.ic_launcher_round
).apply {
setTintBlendMode(BlendMode.DST)
}
}
override fun onDestroy() {
mDatabaseTaskProvider?.unregisterProgressTask()
super.onDestroy()
}
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called")
processGetCredentialsRequest(request)?.let { response ->
callback.onResult(response)
} ?: run {
callback.onError(GetCredentialUnknownException())
}
}
private fun processGetCredentialsRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse? {
val credentialEntries: MutableList<CredentialEntry> = mutableListOf()
for (option in request.beginGetCredentialOptions) {
when (option) {
is BeginGetPublicKeyCredentialOption -> {
credentialEntries.addAll(
populatePasskeyData(option)
)
return BeginGetCredentialResponse(credentialEntries)
}
}
}
Log.w(javaClass.simpleName, "unknown beginGetCredentialOption")
return null
}
private fun populatePasskeyData(option: BeginGetPublicKeyCredentialOption): List<CredentialEntry> {
val passkeyEntries: MutableList<CredentialEntry> = mutableListOf()
val relyingPartyJson = JsonHelper
.parseJsonToRequestOptions(option.requestJson)
.relyingParty
val searchInfo = SearchInfo().apply {
relyingParty = relyingPartyJson
}
Log.d(TAG, "Build passkey search for relying party $relyingPartyJson")
SearchHelper.checkAutoSearchInfo(
context = this,
database = mDatabase,
searchInfo = searchInfo,
onItemsFound = { database, items ->
Log.d(TAG, "Add pending intent for passkey selection with found items")
for (passkeyEntry in items) {
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.SELECTION,
passkeyEntryNodeId = passkeyEntry.id
)?.let { usagePendingIntent ->
val passkey = passkeyEntry.getPasskey()
passkeyEntries.add(
PublicKeyCredentialEntry(
context = applicationContext,
username = passkey?.username ?: "Unknown",
icon = passkeyEntry.buildIcon(this@PasskeyProviderService, database)?.apply {
setTintBlendMode(BlendMode.DST)
} ?: defaultIcon,
pendingIntent = usagePendingIntent,
beginGetPublicKeyCredentialOption = option,
displayName = passkey?.displayName,
isAutoSelectAllowed = false
)
)
}
}
},
onItemNotFound = { _ ->
Log.w(TAG, "No passkey found in the database with this relying party : $relyingPartyJson")
Log.d(TAG, "Add pending intent for passkey selection in opened database")
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.SELECTION,
searchInfo = searchInfo
)?.let { pendingIntent ->
passkeyEntries.add(
PublicKeyCredentialEntry(
context = applicationContext,
username = getString(R.string.passkey_locked_database_username),
displayName = getString(R.string.passkey_selection_description),
icon = defaultIcon,
pendingIntent = pendingIntent,
beginGetPublicKeyCredentialOption = option,
lastUsedTime = Instant.now(),
isAutoSelectAllowed = false
)
)
}
},
onDatabaseClosed = {
Log.d(TAG, "Add pending intent for passkey selection in closed database")
// Database is locked, a public key credential entry is shown to unlock it
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.SELECTION,
searchInfo = searchInfo
)?.let { pendingIntent ->
passkeyEntries.add(
PublicKeyCredentialEntry(
context = applicationContext,
username = getString(R.string.passkey_locked_database_username),
displayName = getString(R.string.passkey_locked_database_description),
icon = defaultIcon,
pendingIntent = pendingIntent,
beginGetPublicKeyCredentialOption = option,
lastUsedTime = Instant.now(),
isAutoSelectAllowed = true
)
)
}
}
)
return passkeyEntries
}
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
) {
Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called")
processCreateCredentialRequest(request)?.let { response ->
callback.onResult(response)
} ?: let {
callback.onError(CreateCredentialUnknownException())
}
}
private fun processCreateCredentialRequest(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse? {
when (request) {
is BeginCreatePublicKeyCredentialRequest -> {
// Request is passkey type
return handleCreatePasskeyQuery(request)
}
}
// request type not supported
Log.w(javaClass.simpleName, "unknown type of BeginCreateCredentialRequest")
return null
}
private fun MutableList<CreateEntry>.addPendingIntentCreationNewEntry(
accountName: String,
searchInfo: SearchInfo?
) {
Log.d(TAG, "Add pending intent for registration in opened database to create new item")
// TODO add a setting to directly store in a specific group
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.REGISTRATION,
searchInfo = searchInfo
)?.let { pendingIntent ->
this.add(
CreateEntry(
accountName = accountName,
icon = defaultIcon,
pendingIntent = pendingIntent,
description = getString(R.string.passkey_creation_description)
)
)
}
}
private fun handleCreatePasskeyQuery(request: BeginCreatePublicKeyCredentialRequest): BeginCreateCredentialResponse {
val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_username)
val createEntries: MutableList<CreateEntry> = mutableListOf()
val searchInfo = SearchInfo().apply {
relyingParty = JsonHelper
.parseJsonToCreateOptions(request.requestJson)
.relyingParty
}
SearchHelper.checkAutoSearchInfo(
context = this,
database = mDatabase,
searchInfo = searchInfo,
onItemsFound = { database, items ->
if (database.isReadOnly) {
throw CreateCredentialUnknownException(
"Unable to register or overwrite a passkey in a database that is read only"
)
} else {
// To create a new entry
createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo)
/* TODO Overwrite
// To select an existing entry and permit an overwrite
Log.w(TAG, "Passkey already registered")
for (entryInfo in items) {
PasskeyHelper.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.REGISTRATION,
searchInfo = searchInfo,
passkeyEntryNodeId = entryInfo.id
)?.let { createPendingIntent ->
createEntries.add(
CreateEntry(
accountName = accountName,
pendingIntent = createPendingIntent,
description = getString(
R.string.passkey_update_description,
entryInfo.getPasskey()?.displayName
)
)
)
}
}*/
}
},
onItemNotFound = { database ->
// To create a new entry
if (database.isReadOnly) {
throw CreateCredentialUnknownException(
"Unable to register a new passkey in a database that is read only"
)
} else {
createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo)
}
},
onDatabaseClosed = {
// Launch the passkey launcher activity to open the database
Log.d(TAG, "Add pending intent for passkey registration in closed database")
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.REGISTRATION
)?.let { pendingIntent ->
createEntries.add(
CreateEntry(
accountName = accountName,
icon = defaultIcon,
pendingIntent = pendingIntent,
description = getString(R.string.passkey_locked_database_description)
)
)
}
}
)
return BeginCreateCredentialResponse(createEntries)
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>
) {
// nothing to do
}
companion object {
private val TAG = PasskeyProviderService::class.java.simpleName
}
}

View File

@@ -0,0 +1,9 @@
package com.kunzisoft.keepass.credentialprovider.passkey.data
data class PublicKeyCredentialCreationOptions(
val relyingParty: String,
val challenge: ByteArray, // TODO Equals Hashcode
val username: String,
val userId: ByteArray, // TODO Equals Hashcode
val keyTypeIdList: List<Long>
)

View File

@@ -0,0 +1,11 @@
package com.kunzisoft.keepass.credentialprovider.passkey.data
import java.security.KeyPair
data class PublicKeyCredentialCreationParameters(
val relyingParty: String,
val credentialId: ByteArray, // TODO Equals Hashcode
val signatureKey: Pair<KeyPair, Long>,
val isPrivilegedApp: Boolean,
val challenge: ByteArray, // TODO Equals Hashcode
)

View File

@@ -1,7 +1,6 @@
package com.kunzisoft.keepass.credentialprovider.data package com.kunzisoft.keepass.credentialprovider.passkey.data
data class PublicKeyCredentialRequestOptions( data class PublicKeyCredentialRequestOptions(
val relyingParty: String, val relyingParty: String,
val challengeString: String val challengeString: String
) { )
}

View File

@@ -0,0 +1,9 @@
package com.kunzisoft.keepass.credentialprovider.passkey.data
data class PublicKeyCredentialUsageParameters(
val relyingParty: String,
val packageName: String? = null,
val clientDataHash: ByteArray?, // TODO Equals Hashcode
val isPrivilegedApp: Boolean,
val challenge: ByteArray, // TODO Equals Hashcode
)

View File

@@ -1,4 +1,4 @@
package com.kunzisoft.keepass.credentialprovider.util package com.kunzisoft.keepass.credentialprovider.passkey.util
class AppRelyingPartyRelation { class AppRelyingPartyRelation {

View File

@@ -1,4 +1,4 @@
package com.kunzisoft.keepass.credentialprovider.util package com.kunzisoft.keepass.credentialprovider.passkey.util
import org.apache.commons.codec.binary.Base64 import org.apache.commons.codec.binary.Base64

View File

@@ -1,12 +1,12 @@
package com.kunzisoft.keepass.credentialprovider.util package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.credentials.webauthn.Cbor import androidx.credentials.webauthn.Cbor
import com.kunzisoft.encrypt.HashManager import com.kunzisoft.encrypt.HashManager
import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialCreationOptions import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions
import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialRequestOptions import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions
import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Decode import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Decode
import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Encode import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@@ -245,7 +245,5 @@ class JsonHelper {
keyTypeIdList.distinct() keyTypeIdList.distinct()
) )
} }
} }
} }

View File

@@ -1,4 +1,4 @@
package com.kunzisoft.keepass.credentialprovider.util package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.content.res.AssetManager import android.content.res.AssetManager
import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.CallingAppInfo
@@ -9,13 +9,12 @@ class OriginHelper {
const val DEFAULT_PROTOCOL = "https://" const val DEFAULT_PROTOCOL = "https://"
fun getWebOrigin(callingAppInfo: CallingAppInfo, assets: AssetManager): String? { fun getWebOrigin(callingAppInfo: CallingAppInfo?, assets: AssetManager): String? {
val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use {
it.readText() it.readText()
} }
// for trusted browsers like Chrome and Firefox // for trusted browsers like Chrome and Firefox
val origin = callingAppInfo.getOrigin(privilegedAllowlist)?.removeSuffix("/") return callingAppInfo?.getOrigin(privilegedAllowlist)?.removeSuffix("/")
return origin
} }
} }

View File

@@ -0,0 +1,428 @@
/*
* Copyright 2025 Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
* KeePassDX is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* KeePassDX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.ParcelUuid
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import com.kunzisoft.asymmetric.Signature
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginHelper.Companion.DEFAULT_PROTOCOL
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.utils.StringUtil.toHexString
import com.kunzisoft.keepass.utils.getParcelableExtraCompat
import com.kunzisoft.random.KeePassDXRandom
import java.security.KeyStore
import java.security.MessageDigest
import java.time.Instant
import java.util.UUID
import javax.crypto.KeyGenerator
import javax.crypto.Mac
import javax.crypto.SecretKey
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
object PasskeyHelper {
private const val EXTRA_PASSKEY_ELEMENT = "com.kunzisoft.keepass.passkey.extra.EXTRA_PASSKEY_ELEMENT"
private const val HMAC_TYPE = "HmacSHA256"
private const val KEY_NODE_ID = "nodeId"
private const val KEY_TIMESTAMP = "timestamp"
private const val KEY_AUTHENTICATION_CODE = "authenticationCode"
private const val SEPARATOR = "_"
private const val NAME_OF_HMAC_KEY = "KeePassDXCredentialProviderHMACKey"
private const val KEYSTORE_TYPE = "AndroidKeyStore"
private val PLACEHOLDER_FOR_NEW_NODE_ID = "0".repeat(32)
private val REGEX_TIMESTAMP = "[0-9]{10}".toRegex()
private val REGEX_AUTHENTICATION_CODE = "[A-F0-9]{64}".toRegex() // 256 bits = 64 hex chars
private const val MAX_DIFF_IN_SECONDS = 60
/**
* Build the Passkey response for one entry
*/
fun Activity.buildPasskeyResponseAndSetResult(
entryInfo: EntryInfo,
extras: Bundle? = null
) {
try {
entryInfo.getPasskey()?.let {
val mReplyIntent = Intent()
Log.d(javaClass.name, "Success Passkey manual selection")
mReplyIntent.putExtra(EXTRA_PASSKEY_ELEMENT, entryInfo.getPasskey())
extras?.let {
mReplyIntent.putExtras(it)
}
setResult(Activity.RESULT_OK, mReplyIntent)
} ?: run {
Log.w(javaClass.name, "Failed Passkey manual selection")
setResult(Activity.RESULT_CANCELED)
}
} catch (e: Exception) {
Log.e(javaClass.name, "Cant add passkey entry as result", e)
setResult(Activity.RESULT_CANCELED)
}
}
fun Intent.addAuthCode(passkeyEntryNodeId: UUID? = null) {
passkeyEntryNodeId?.let {
putExtras(Bundle().apply {
val timestamp = Instant.now().epochSecond
putParcelable(KEY_NODE_ID, ParcelUuid(passkeyEntryNodeId))
putString(KEY_TIMESTAMP, timestamp.toString())
putString(
KEY_AUTHENTICATION_CODE, generatedAuthenticationCode(
passkeyEntryNodeId, timestamp
).toHexString()
)
})
}
}
fun Intent.retrievePasskey(): Passkey? {
return this.getParcelableExtraCompat(EXTRA_PASSKEY_ELEMENT)
}
fun Intent.removePasskey() {
return this.removeExtra(EXTRA_PASSKEY_ELEMENT)
}
fun Intent.retrieveNodeId(): UUID? {
return getParcelableExtraCompat<ParcelUuid>(KEY_NODE_ID)?.uuid
}
fun checkSecurity(intent: Intent, nodeId: UUID?) {
val timestampString = intent.getStringExtra(KEY_TIMESTAMP)
if (timestampString.isNullOrEmpty())
throw CreateCredentialUnknownException("Timestamp null")
if (timestampString.matches(REGEX_TIMESTAMP).not()) {
throw CreateCredentialUnknownException("Timestamp not valid")
}
val timestamp = timestampString.toLong()
val diff = Instant.now().epochSecond - timestamp
if (diff < 0 || diff > MAX_DIFF_IN_SECONDS) {
throw CreateCredentialUnknownException("Out of time")
}
verifyAuthenticationCode(
intent.getStringExtra(KEY_AUTHENTICATION_CODE),
generatedAuthenticationCode(nodeId, timestamp)
)
}
private fun generatedAuthenticationCode(nodeId: UUID?, timestamp: Long): ByteArray {
return generateAuthenticationCode(
(nodeId?.toString() ?: PLACEHOLDER_FOR_NEW_NODE_ID) + SEPARATOR + timestamp.toString()
)
}
private fun verifyAuthenticationCode(
valueToCheck: String?,
authenticationCode: ByteArray
) {
if (valueToCheck.isNullOrEmpty())
throw CreateCredentialUnknownException("Authentication code empty")
if (valueToCheck.matches(REGEX_AUTHENTICATION_CODE).not())
throw CreateCredentialUnknownException("Authentication not valid")
if (MessageDigest.isEqual(authenticationCode, generateAuthenticationCode(valueToCheck)))
throw CreateCredentialUnknownException("Authentication code incorrect")
}
private fun generateAuthenticationCode(message: String): ByteArray {
val keyStore = KeyStore.getInstance(KEYSTORE_TYPE)
keyStore.load(null)
val hmacKey = try {
keyStore.getKey(NAME_OF_HMAC_KEY, null) as SecretKey
} catch (e: Exception) {
// key not found
generateKey()
}
val mac = Mac.getInstance(HMAC_TYPE)
mac.init(hmacKey)
val authenticationCode = mac.doFinal(message.toByteArray())
return authenticationCode
}
private fun generateKey(): SecretKey? {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_TYPE
)
val keySizeInBits = 128
keyGenerator.init(
KeyGenParameterSpec.Builder(NAME_OF_HMAC_KEY, KeyProperties.PURPOSE_SIGN)
.setKeySize(keySizeInBits)
.build()
)
val key = keyGenerator.generateKey()
return key
}
private fun String.decodeHexToByteArray(): ByteArray {
if (length % 2 != 0) {
throw IllegalArgumentException("Must have an even length")
}
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
fun Intent.retrievePasskeyCreationComponent(): PublicKeyCredentialCreationOptions {
val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(this)
?: throw CreateCredentialUnknownException("could not retrieve request from intent")
if (request.callingRequest !is CreatePublicKeyCredentialRequest) {
throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}")
}
return JsonHelper.parseJsonToCreateOptions(
(request.callingRequest as CreatePublicKeyCredentialRequest).requestJson
)
}
fun Intent.retrievePasskeyUsageComponent(): GetPublicKeyCredentialOption {
val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(this)
?: throw CreateCredentialUnknownException("could not retrieve request from intent")
if (request.credentialOptions.size != 1) {
throw GetCredentialUnknownException("not exact one credentialOption")
}
if (request.credentialOptions[0] !is GetPublicKeyCredentialOption) {
throw CreateCredentialUnknownException("credentialOptions is of wrong type: ${request.credentialOptions[0]}")
}
return request.credentialOptions[0] as GetPublicKeyCredentialOption
}
fun retrievePasskeyCreationRequestParameters(
creationOptions: PublicKeyCredentialCreationOptions,
webOrigin: String?,
apkSigningCertificate: ByteArray?,
passkeyCreated: (Passkey, PublicKeyCredentialCreationParameters) -> Unit
) {
val relyingParty = creationOptions.relyingParty
val username = creationOptions.username
val userHandle = creationOptions.userId
val keyTypeIdList = creationOptions.keyTypeIdList
val challenge = creationOptions.challenge
val isPrivilegedApp =
(webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty)
Log.d(this::class.java.simpleName, "isPrivilegedApp = $isPrivilegedApp")
if (!isPrivilegedApp) {
val isValid =
AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate)
if (!isValid) {
throw CreateCredentialUnknownException(
"could not verify relation between app " +
"and relyingParty $relyingParty"
)
}
}
val credentialId = KeePassDXRandom.generateCredentialId()
val (keyPair, keyTypeId) = Signature.generateKeyPair(keyTypeIdList)
?: throw CreateCredentialUnknownException("no known public key type found")
val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private)
// create new entry in database
passkeyCreated.invoke(
Passkey(
username = username,
displayName = "$relyingParty (Passkey)",
privateKeyPem = privateKeyPem,
credentialId = Base64Helper.b64Encode(credentialId),
userHandle = Base64Helper.b64Encode(userHandle),
relyingParty = DEFAULT_PROTOCOL + relyingParty
),
PublicKeyCredentialCreationParameters(
relyingParty = relyingParty,
challenge = challenge,
credentialId = credentialId,
signatureKey = Pair(keyPair, keyTypeId),
isPrivilegedApp = isPrivilegedApp
)
)
}
fun buildCreatePublicKeyCredentialResponse(
packageName: String?,
publicKeyCredentialCreationParameters: PublicKeyCredentialCreationParameters
): CreatePublicKeyCredentialResponse {
val keyPair = publicKeyCredentialCreationParameters.signatureKey.first
val keyTypeId = publicKeyCredentialCreationParameters.signatureKey.second
val publicKeyEncoded = Signature.convertPublicKey(keyPair.public, keyTypeId)
val publicKeyMap = Signature.convertPublicKeyToMap(keyPair.public, keyTypeId)
val authData = JsonHelper.generateAuthDataForCreate(
userPresent = true,
userVerified = true,
backupEligibility = true,
backupState = true,
rpId = publicKeyCredentialCreationParameters.relyingParty.toByteArray(),
credentialId = publicKeyCredentialCreationParameters.credentialId,
credentialPublicKey = JsonHelper.generateCborFromMap(publicKeyMap!!)
)
val attestationObject = JsonHelper.generateAttestationObject(authData)
val clientJson: String
if (publicKeyCredentialCreationParameters.isPrivilegedApp) {
clientJson = JsonHelper.generateClientDataJsonPrivileged()
} else {
val origin = DEFAULT_PROTOCOL + publicKeyCredentialCreationParameters.relyingParty
clientJson = JsonHelper.generateClientDataJsonNonPrivileged(
publicKeyCredentialCreationParameters.challenge,
origin,
packageName,
isCrossOriginAdded = true,
isGet = false
)
}
val responseJson = JsonHelper.createAuthenticatorAttestationResponseJSON(
publicKeyCredentialCreationParameters.credentialId,
clientJson,
attestationObject,
publicKeyEncoded!!,
authData,
keyTypeId
)
// log only the length to prevent logging sensitive information
Log.d(javaClass.simpleName, "responseJson with length ${responseJson.length} created")
return CreatePublicKeyCredentialResponse(responseJson)
}
fun retrievePasskeyUsageRequestParameters(
context: Context,
intent: Intent,
result: (PublicKeyCredentialUsageParameters) -> Unit
) {
val callingAppInfo = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.callingAppInfo
val credentialOption = intent.retrievePasskeyUsageComponent()
val clientDataHash = credentialOption.clientDataHash
val requestOptions = JsonHelper.parseJsonToRequestOptions(credentialOption.requestJson)
val relyingParty = requestOptions.relyingParty
val challenge = Base64Helper.b64Decode(requestOptions.challengeString)
val packageName = callingAppInfo?.packageName
val webOrigin = OriginHelper.getWebOrigin(callingAppInfo, context.assets)
val isPrivilegedApp =
(webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty && clientDataHash != null)
Log.d(javaClass.simpleName, "isPrivilegedApp = $isPrivilegedApp")
if (!isPrivilegedApp) {
if (!AppRelyingPartyRelation.isRelationValid(
relyingParty,
apkSigningCertificate = callingAppInfo?.signingInfo?.apkContentsSigners
?.getOrNull(0)?.toByteArray()
)) {
throw CreateCredentialUnknownException(
"could not verify relation between app " +
"and relyingParty $relyingParty"
)
}
}
result.invoke(
PublicKeyCredentialUsageParameters(
relyingParty = relyingParty,
packageName = packageName,
clientDataHash = clientDataHash,
isPrivilegedApp = isPrivilegedApp,
challenge = challenge
)
)
}
fun buildPasskeyPublicKeyCredential(
usageParameters: PublicKeyCredentialUsageParameters,
passkey: Passkey
): PublicKeyCredential {
// https://www.w3.org/TR/webauthn-3/#authdata-flags
val authenticatorData = JsonHelper.generateAuthDataForUsage(
usageParameters.relyingParty.toByteArray(),
userPresent = true,
userVerified = true,
backupEligibility = true,
backupState = true
)
val clientDataJson: String
val dataToSign: ByteArray
if (usageParameters.isPrivilegedApp) {
clientDataJson = JsonHelper.generateClientDataJsonPrivileged()
dataToSign =
JsonHelper.generateDataToSignPrivileged(usageParameters.clientDataHash!!, authenticatorData)
} else {
val origin = DEFAULT_PROTOCOL + usageParameters.relyingParty
clientDataJson = JsonHelper.generateClientDataJsonNonPrivileged(
usageParameters.challenge,
origin,
usageParameters.packageName,
isGet = true,
isCrossOriginAdded = false
)
dataToSign =
JsonHelper.generateDataTosSignNonPrivileged(clientDataJson, authenticatorData)
}
val signature = Signature.sign(passkey.privateKeyPem, dataToSign)
?: throw GetCredentialUnknownException("signing failed")
val getCredentialResponse =
JsonHelper.generateGetCredentialResponse(
clientDataJson.toByteArray(),
authenticatorData,
signature,
passkey.userHandle,
passkey.credentialId
)
return PublicKeyCredential(getCredentialResponse)
}
}

View File

@@ -1,219 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.service
import android.os.Build
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.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
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.CredentialEntry
import androidx.credentials.provider.CredentialProviderService
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.credentialprovider.data.Passkey
import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper
import com.kunzisoft.keepass.credentialprovider.util.IntentHelper
import com.kunzisoft.keepass.credentialprovider.util.JsonHelper
import com.kunzisoft.keepass.database.DatabaseTaskProvider
import com.kunzisoft.keepass.database.element.Database
import java.time.Instant
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class KeePassDXCredentialProviderService : CredentialProviderService() {
private var mDatabaseTaskProvider: DatabaseTaskProvider? = null
private var mDatabase: Database? = null
override fun onCreate() {
super.onCreate()
mDatabaseTaskProvider = DatabaseTaskProvider(this)
mDatabaseTaskProvider?.registerProgressTask()
mDatabaseTaskProvider?.onDatabaseRetrieved = { database ->
this.mDatabase = database
}
}
override fun onDestroy() {
mDatabaseTaskProvider?.unregisterProgressTask()
super.onDestroy()
}
override fun onBeginCreateCredentialRequest(
request: BeginCreateCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
) {
Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called")
processCreateCredentialRequest(request)?.let { response ->
callback.onResult(response)
} ?: let {
callback.onError(CreateCredentialUnknownException())
}
}
private fun processCreateCredentialRequest(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse? {
when (request) {
is BeginCreatePublicKeyCredentialRequest -> {
// Request is passkey type
return handleCreatePasskeyQuery(request)
}
}
// request type not supported
Log.w(javaClass.simpleName, "unknown type of BeginCreateCredentialRequest")
return null
}
private fun handleCreatePasskeyQuery(request: BeginCreatePublicKeyCredentialRequest): BeginCreateCredentialResponse {
val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_account_name)
val createEntries: MutableList<CreateEntry> = mutableListOf()
mDatabase?.let { database ->
// To create a new entry
IntentHelper.generateCreatePendingIntent(applicationContext)
?.let { pendingIntentNewEntry ->
createEntries.add(
CreateEntry(
accountName = accountName,
pendingIntent = pendingIntentNewEntry,
description = getString(R.string.passkey_creation_description)
)
)
}
// To select an existing entry
for (passkey in getCredentialsFromDb(
relyingPartyId = JsonHelper.parseJsonToCreateOptions(request.requestJson).relyingParty,
database = database
)) {
IntentHelper.generateCreatePendingIntent(applicationContext, passkey.nodeId)
?.let { createPendingIntent ->
createEntries.add(
CreateEntry(
accountName = accountName,
pendingIntent = createPendingIntent,
description = getString(
R.string.passkey_update_description,
passkey.displayName
)
)
)
}
}
} ?: run {
// Database is locked, an entry is shown to unlock it
createEntries.add(
CreateEntry(
accountName = accountName,
pendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext),
description = getString(R.string.passkey_locked_database_description)
)
)
}
return BeginCreateCredentialResponse(createEntries)
}
override fun onBeginGetCredentialRequest(
request: BeginGetCredentialRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called")
processGetCredentialsRequest(request)?.let { response ->
callback.onResult(response)
} ?: run {
callback.onError(GetCredentialUnknownException())
}
}
private fun processGetCredentialsRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse? {
val credentialEntries: MutableList<CredentialEntry> = mutableListOf()
for (option in request.beginGetCredentialOptions) {
when (option) {
is BeginGetPublicKeyCredentialOption -> {
credentialEntries.addAll(
populatePasskeyData(option)
)
return BeginGetCredentialResponse(credentialEntries)
}
}
}
Log.w(javaClass.simpleName, "unknown beginGetCredentialOption")
return null
}
private fun populatePasskeyData(option: BeginGetPublicKeyCredentialOption): List<CredentialEntry> {
val passkeyEntries: MutableList<CredentialEntry> = mutableListOf()
mDatabase?.let { database ->
// Retrieve passkeys entries from database
val relyingParty = JsonHelper.parseJsonToRequestOptions(option.requestJson).relyingParty
if (relyingParty.isBlank()) {
throw CreateCredentialUnknownException("relying party id is null or blank")
}
for (passkey in getCredentialsFromDb(
relyingPartyId = relyingParty,
database = database
)) {
IntentHelper.generateUsagePendingIntent(applicationContext, passkey.nodeId)
?.let { usagePendingIntent ->
passkeyEntries.add(
PublicKeyCredentialEntry(
context = applicationContext,
username = passkey.username,
pendingIntent = usagePendingIntent,
beginGetPublicKeyCredentialOption = option,
displayName = passkey.displayName,
isAutoSelectAllowed = false
)
)
}
}
} ?: run {
// Database is locked, a public key credential entry is shown to unlock it
passkeyEntries.add(
PublicKeyCredentialEntry(
context = applicationContext,
username = getString(R.string.passkey_locked_database_account_name),
pendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext),
beginGetPublicKeyCredentialOption = option,
displayName = getString(R.string.passkey_locked_database_description),
lastUsedTime = Instant.now(),
isAutoSelectAllowed = true
)
)
}
return passkeyEntries
}
private fun getCredentialsFromDb(relyingPartyId: String, database: Database): List<Passkey> {
val passkeys = DatabaseHelper.getAllPasskeys(database)
val passkeysMatching = passkeys.filter { p -> p.relyingParty == relyingPartyId }
return passkeysMatching
}
override fun onClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<Void?, ClearCredentialException>
) {
// nothing to do
}
}

View File

@@ -1,98 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.util
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import com.kunzisoft.keepass.credentialprovider.data.Passkey
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.DatabaseTaskProvider
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.Entry
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.search.SearchHelper
import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.utils.UuidUtil
@RequiresApi(Build.VERSION_CODES.O)
class DatabaseHelper {
companion object {
fun getAllPasskeys(database: Database): List<Passkey> {
val searchHelper = SearchHelper()
val searchParameters = SearchParameters().apply {
searchQuery = PasskeyConverter.PASSKEY_TAG
searchInTitles = false
searchInUsernames = false
searchInPasswords = false
searchInUrls = false
searchInNotes = false
searchInOTP = false
searchInOther = false
searchInUUIDs = false
searchInTags = true
searchInCurrentGroup = false
searchInSearchableGroup = false
searchInRecycleBin = false
searchInTemplates = false
}
val fromGroup = null
val max = Int.MAX_VALUE
val searchResult = searchHelper.createVirtualGroupWithSearchResult(
database,
searchParameters,
fromGroup,
max
)
?: return emptyList()
return PasskeyConverter.convertEntriesListToPasskeys(searchResult.getChildEntries())
}
fun searchPassKeyByNodeId(database: Database, nodeId: String): Passkey? {
val uuidToSearch = UuidUtil.fromHexString(nodeId) ?: return null
val nodeIdUUIDToSearch = NodeIdUUID(uuidToSearch)
val entry = database.getEntryById(nodeIdUUIDToSearch) ?: return null
return PasskeyConverter.convertEntryToPasskey(entry)
}
fun updateEntry(
database: Database,
databaseTaskProvider: DatabaseTaskProvider,
updatedPasskey: Passkey
) {
val oldEntry = Entry(updatedPasskey.databaseEntry!!)
val entryToUpdate = Entry(updatedPasskey.databaseEntry)
PasskeyConverter.setPasskeyInEntry(updatedPasskey, entryToUpdate)
entryToUpdate.setEntryInfo(
database,
entryToUpdate.getEntryInfo(
database,
raw = true,
removeTemplateConfiguration = false
)
)
val save = true
databaseTaskProvider.startDatabaseUpdateEntry(oldEntry, entryToUpdate, save)
Log.d(this::class.java.simpleName, "passkey in entry ${oldEntry.title} updated")
}
fun saveNewEntry(
database: ContextualDatabase,
databaseTaskProvider: DatabaseTaskProvider,
newPasskey: Passkey
) {
val newEntry = database.createEntry() ?: throw Exception("can not create new entry")
PasskeyConverter.setPasskeyInEntry(newPasskey, newEntry)
val save = true
val group = database.rootGroup
?: throw Exception("can not save new entry in database, because rootGroup is null")
databaseTaskProvider.startDatabaseCreateEntry(newEntry, group, save)
Log.d(this::class.java.simpleName, "new entry saved")
}
}
}

View File

@@ -1,195 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.util
import android.app.Activity
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import androidx.annotation.RequiresApi
import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity
import com.kunzisoft.keepass.credentialprovider.activity.CreatePasskeyActivity
import com.kunzisoft.keepass.credentialprovider.activity.UsePasskeyActivity
import com.kunzisoft.keepass.utils.StringUtil.toHexString
import java.security.KeyStore
import java.security.MessageDigest
import java.time.Instant
import javax.crypto.KeyGenerator
import javax.crypto.Mac
import javax.crypto.SecretKey
@RequiresApi(Build.VERSION_CODES.O)
class IntentHelper {
companion object {
private const val HMAC_TYPE = "HmacSHA256"
private const val KEY_NODE_ID = "nodeId"
private const val KEY_TIMESTAMP = "timestamp"
private const val KEY_AUTHENTICATION_CODE = "authenticationCode"
private const val SEPARATOR = "_"
private const val NAME_OF_HMAC_KEY = "KeePassDXCredentialProviderHMACKey"
private const val KEYSTORE_TYPE = "AndroidKeyStore"
private val PLACEHOLDER_FOR_NEW_NODE_ID = "0".repeat(32)
private val REGEX_NODE_ID = "[A-F0-9]{32}".toRegex()
private val REGEX_TIMESTAMP = "[0-9]{10}".toRegex()
private val REGEX_AUTHENTICATION_CODE = "[A-F0-9]{64}".toRegex() // 256 bits = 64 hex chars
private const val MAX_DIFF_IN_SECONDS = 60
private var currentRequestCode: Int = 0
private fun <T : Activity> createPendingIntent(
clazz: Class<T>,
applicationContext: Context,
data: Bundle? = null
): PendingIntent {
val intent = Intent().setClass(applicationContext, clazz)
data?.let { intent.putExtras(data) }
val requestCode = currentRequestCode
// keeps the requestCodes unique, the limit is arbitrary
currentRequestCode = (currentRequestCode + 1) % 1000
return PendingIntent.getActivity(
applicationContext,
requestCode,
intent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
private fun <T : Activity> createPendingIntentWithAuthenticationCode(
clazz: Class<T>,
applicationContext: Context,
nodeId: String
): PendingIntent? {
if (nodeId.matches(REGEX_NODE_ID).not()) return null
val data = Bundle()
val timestamp = Instant.now().epochSecond.toString()
data.putString(KEY_NODE_ID, nodeId)
data.putString(KEY_TIMESTAMP, timestamp)
val message = nodeId + SEPARATOR + timestamp
val authenticationCode = generateAuthenticationCode(message).toHexString()
data.putString(KEY_AUTHENTICATION_CODE, authenticationCode)
return createPendingIntent(clazz, applicationContext, data)
}
fun generateUnlockPendingIntent(applicationContext: Context): PendingIntent {
// TODO after the database is unlocked by the user, return to the flow
return createPendingIntent(FileDatabaseSelectActivity::class.java, applicationContext)
}
fun generateCreatePendingIntent(
applicationContext: Context,
nodeId: String = PLACEHOLDER_FOR_NEW_NODE_ID
): PendingIntent? {
return createPendingIntentWithAuthenticationCode(
CreatePasskeyActivity::class.java,
applicationContext,
nodeId
)
}
fun generateUsagePendingIntent(
applicationContext: Context,
nodeId: String
): PendingIntent? {
return createPendingIntentWithAuthenticationCode(
UsePasskeyActivity::class.java,
applicationContext,
nodeId
)
}
fun getVerifiedNodeId(intent: Intent): String? {
val nodeId = intent.getStringExtra(KEY_NODE_ID) ?: return null
val timestampString = intent.getStringExtra(KEY_TIMESTAMP) ?: return null
val authenticationCode = intent.getStringExtra(KEY_AUTHENTICATION_CODE) ?: return null
if (nodeId.matches(REGEX_NODE_ID).not() ||
timestampString.matches(REGEX_TIMESTAMP).not() ||
authenticationCode.matches(REGEX_AUTHENTICATION_CODE).not()
) {
return null
}
val diff = Instant.now().epochSecond - timestampString.toLong()
if (diff < 0 || diff > MAX_DIFF_IN_SECONDS) {
return null
}
val message = (nodeId + SEPARATOR + timestampString)
if (verifyAuthenticationCode(
message,
authenticationCode.decodeHexToByteArray()
).not()
) {
return null
}
Log.d(this::class.java.simpleName, "nodeId $nodeId verified")
return nodeId
}
private fun verifyAuthenticationCode(
message: String,
authenticationCodeIn: ByteArray
): Boolean {
val authenticationCode = generateAuthenticationCode(message)
return MessageDigest.isEqual(authenticationCodeIn, authenticationCode)
}
private fun generateAuthenticationCode(message: String): ByteArray {
val keyStore = KeyStore.getInstance(KEYSTORE_TYPE)
keyStore.load(null)
val hmacKey = try {
keyStore.getKey(NAME_OF_HMAC_KEY, null) as SecretKey
} catch (e: Exception) {
// key not found
generateKey()
}
val mac = Mac.getInstance(HMAC_TYPE)
mac.init(hmacKey)
val authenticationCode = mac.doFinal(message.toByteArray())
return authenticationCode
}
private fun generateKey(): SecretKey? {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_TYPE
)
val keySizeInBits = 128
keyGenerator.init(
KeyGenParameterSpec.Builder(NAME_OF_HMAC_KEY, KeyProperties.PURPOSE_SIGN)
.setKeySize(keySizeInBits)
.build()
)
val key = keyGenerator.generateKey()
return key
}
fun isPlaceholderNodeId(nodeId: String): Boolean {
return nodeId == PLACEHOLDER_FOR_NEW_NODE_ID
}
private fun String.decodeHexToByteArray(): ByteArray {
if (length % 2 != 0) {
throw IllegalArgumentException("Must have an even length")
}
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}
}

View File

@@ -1,120 +0,0 @@
package com.kunzisoft.keepass.credentialprovider.util
import android.os.Build
import androidx.annotation.RequiresApi
import com.kunzisoft.keepass.credentialprovider.data.Passkey
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.security.ProtectedString
import com.kunzisoft.keepass.utils.UuidUtil
@RequiresApi(Build.VERSION_CODES.O)
class PasskeyConverter {
companion object {
// field names from KeypassXC are used
private const val FIELD_USERNAME = "KPEX_PASSKEY_USERNAME"
private const val FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM"
private const val FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID"
private const val FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE"
private const val FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY"
const val PASSKEY_TAG = "Passkey"
fun convertEntryToPasskey(entry: Entry): Passkey? {
if (entry.tags.toList().contains(PASSKEY_TAG).not()) {
return null
}
val nodeId = UuidUtil.toHexString(entry.nodeId.id) ?: return null
val displayName = entry.getVisualTitle()
var username = ""
var privateKeyPem = ""
var credId = ""
var userHandle = ""
var relyingParty = ""
for (field in entry.getExtraFields()) {
val fieldName = field.name
if (fieldName == FIELD_USERNAME) {
username = field.protectedValue.stringValue
} else if (field.name == FIELD_PRIVATE_KEY) {
privateKeyPem = field.protectedValue.stringValue
} else if (field.name == FIELD_CREDENTIAL_ID) {
credId = field.protectedValue.stringValue
} else if (field.name == FIELD_USER_HANDLE) {
userHandle = field.protectedValue.stringValue
} else if (field.name == FIELD_RELYING_PARTY) {
relyingParty = field.protectedValue.stringValue
}
}
return Passkey(
nodeId,
username,
displayName,
privateKeyPem,
credId,
userHandle,
relyingParty,
entry
)
}
fun convertEntriesListToPasskeys(entries: List<Entry>): List<Passkey> {
return entries.mapNotNull { e -> convertEntryToPasskey(e) }
}
fun setPasskeyInEntry(passkey: Passkey, entry: Entry) {
entry.tags.put(PASSKEY_TAG)
entry.title = passkey.displayName
entry.lastModificationTime = DateInstant()
entry.username = passkey.username
entry.url = OriginHelper.DEFAULT_PROTOCOL + passkey.relyingParty
val protected = true
val unProtected = false
entry.putExtraField(
Field(
FIELD_USERNAME,
ProtectedString(unProtected, passkey.username)
)
)
entry.putExtraField(
Field(
FIELD_PRIVATE_KEY,
ProtectedString(protected, passkey.privateKeyPem)
)
)
entry.putExtraField(
Field(
FIELD_CREDENTIAL_ID,
ProtectedString(protected, passkey.credId)
)
)
entry.putExtraField(
Field(
FIELD_USER_HANDLE,
ProtectedString(protected, passkey.userHandle)
)
)
entry.putExtraField(
Field(
FIELD_RELYING_PARTY,
ProtectedString(unProtected, passkey.relyingParty)
)
)
}
}
}

View File

@@ -43,13 +43,15 @@ object SearchHelper {
/** /**
* Utility method to perform actions if item is found or not after an auto search in [database] * Utility method to perform actions if item is found or not after an auto search in [database]
*/ */
fun checkAutoSearchInfo(context: Context, fun checkAutoSearchInfo(
database: ContextualDatabase?, context: Context,
searchInfo: SearchInfo?, database: ContextualDatabase?,
onItemsFound: (openedDatabase: ContextualDatabase, searchInfo: SearchInfo?,
items: List<EntryInfo>) -> Unit, onItemsFound: (openedDatabase: ContextualDatabase,
onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit, items: List<EntryInfo>) -> Unit,
onDatabaseClosed: () -> Unit) { onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit,
onDatabaseClosed: () -> Unit
) {
if (database == null || !database.loaded) { if (database == null || !database.loaded) {
onDatabaseClosed.invoke() onDatabaseClosed.invoke()
} else if (TimeoutHelper.checkTime(context)) { } else if (TimeoutHelper.checkTime(context)) {
@@ -59,8 +61,7 @@ object SearchHelper {
&& !searchInfo.containsOnlyNullValues()) { && !searchInfo.containsOnlyNullValues()) {
// If search provide results // If search provide results
database.createVirtualGroupFromSearchInfo( database.createVirtualGroupFromSearchInfo(
searchInfo.toString(), searchInfo,
searchInfo.isASearchByDomain(),
MAX_SEARCH_ENTRY MAX_SEARCH_ENTRY
)?.let { searchGroup -> )?.let { searchGroup ->
if (searchGroup.numberOfChildEntries > 0) { if (searchGroup.numberOfChildEntries > 0) {

View File

@@ -1,13 +1,9 @@
package com.kunzisoft.keepass.receivers package com.kunzisoft.keepass.receivers
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.utils.DexUtil
import com.kunzisoft.keepass.utils.MagikeyboardUtil import com.kunzisoft.keepass.utils.MagikeyboardUtil
class DexModeReceiver : BroadcastReceiver() { class DexModeReceiver : BroadcastReceiver() {

View File

@@ -27,7 +27,7 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.timeout.TimeoutHelper import com.kunzisoft.keepass.timeout.TimeoutHelper

View File

@@ -32,7 +32,7 @@ import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.services.ClipboardEntryNotificationService import com.kunzisoft.keepass.services.ClipboardEntryNotificationService
import com.kunzisoft.keepass.services.KeyboardEntryNotificationService import com.kunzisoft.keepass.services.KeyboardEntryNotificationService
import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.PreferencesUtil

View File

@@ -4,7 +4,7 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
object MagikeyboardUtil { object MagikeyboardUtil {
private val TAG = MagikeyboardUtil::class.java.name private val TAG = MagikeyboardUtil::class.java.name

View File

@@ -93,7 +93,7 @@
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:textColor="@color/grey_blue_slighter"/> android:textColor="@color/grey_blue_slighter"/>
<com.kunzisoft.keepass.magikeyboard.KeyboardView <com.kunzisoft.keepass.credentialprovider.magikeyboard.KeyboardView
android:id="@+id/magikeyboard_view" android:id="@+id/magikeyboard_view"
style="@style/KeepassDXStyle.Keyboard" style="@style/KeepassDXStyle.Keyboard"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -739,14 +739,12 @@
<string name="hide_expired_entries_summary">Expired entries are not shown</string> <string name="hide_expired_entries_summary">Expired entries are not shown</string>
<string name="hide_templates_title">Hide templates</string> <string name="hide_templates_title">Hide templates</string>
<string name="hide_templates_summary">Templates are not shown</string> <string name="hide_templates_summary">Templates are not shown</string>
<string name="passkey_usage_biometric_prompt_title">Confirm passkey usage</string> <string name="passkey">Passkey</string>
<string name="passkey_usage_biometric_prompt_subtitle">for %1$s</string> <string name="passkey_service_name">KeePassDX Credential Provider</string>
<string name="passkey_usage_biometric_prompt_negative_button_text">Cancel</string>
<string name="passkey_creation_biometric_prompt_title">Confirm passkey creation</string>
<string name="passkey_creation_biometric_prompt_subtitle">for %1$s</string>
<string name="passkey_creation_biometric_prompt_negative_button_text">Cancel</string>
<string name="passkey_creation_description">Save passkey in new entry</string> <string name="passkey_creation_description">Save passkey in new entry</string>
<string name="passkey_update_description">Update passkey in "%1$s"</string> <string name="passkey_update_description">Update passkey in "%1$s"</string>
<string name="passkey_selection_username">No passkey found</string>
<string name="passkey_selection_description">Select an existing passkey</string>
<string name="passkey_locked_database_username">KeePassDX Database Locked</string>
<string name="passkey_locked_database_description">Select to unlock</string> <string name="passkey_locked_database_description">Select to unlock</string>
<string name="passkey_locked_database_account_name">KeePassDX Database Locked</string>
</resources> </resources>

View File

@@ -1,5 +1,6 @@
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
android { android {
namespace 'com.kunzisoft.keepass.database' namespace 'com.kunzisoft.keepass.database'

View File

@@ -55,6 +55,7 @@ import com.kunzisoft.keepass.database.merge.DatabaseKDBXMerger
import com.kunzisoft.keepass.database.search.SearchHelper import com.kunzisoft.keepass.database.search.SearchHelper
import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.hardware.HardwareKey import com.kunzisoft.keepass.hardware.HardwareKey
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
import com.kunzisoft.keepass.utils.SingletonHolder import com.kunzisoft.keepass.utils.SingletonHolder
import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorInt import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorInt
@@ -885,28 +886,15 @@ open class Database {
} }
fun createVirtualGroupFromSearchInfo( fun createVirtualGroupFromSearchInfo(
searchInfoString: String, searchInfo: SearchInfo,
searchInfoByDomain: Boolean,
max: Int = Integer.MAX_VALUE max: Int = Integer.MAX_VALUE
): Group? { ): Group? {
return mSearchHelper.createVirtualGroupWithSearchResult(this, return mSearchHelper.createVirtualGroupWithSearchResult(
SearchParameters().apply { database = this,
searchQuery = searchInfoString searchParameters = searchInfo.buildSearchParameters(),
searchInTitles = true fromGroup = null,
searchInUsernames = false max = max
searchInPasswords = false )
searchInUrls = true
searchByDomain = searchInfoByDomain
searchInNotes = true
searchInOTP = false
searchInOther = true
searchInUUIDs = false
searchInTags = false
searchInCurrentGroup = false
searchInSearchableGroup = true
searchInRecycleBin = false
searchInTemplates = false
}, null, max)
} }
val tagPool: Tags val tagPool: Tags

View File

@@ -49,6 +49,10 @@ class Tags: Parcelable {
} }
} }
fun contains(tag: String): Boolean {
return mTags.contains(tag)
}
fun isEmpty(): Boolean { fun isEmpty(): Boolean {
return mTags.isEmpty() return mTags.isEmpty()
} }

View File

@@ -24,6 +24,7 @@ import com.kunzisoft.keepass.database.element.Entry
import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.Group
import com.kunzisoft.keepass.database.element.node.NodeHandler import com.kunzisoft.keepass.database.element.node.NodeHandler
import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.model.EntryInfoPasskey.FIELD_RELYING_PARTY
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_FIELD import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_FIELD
import com.kunzisoft.keepass.utils.UuidUtil import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.utils.inTheSameDomainAs import com.kunzisoft.keepass.utils.inTheSameDomainAs
@@ -171,8 +172,10 @@ class SearchHelper {
} }
if (searchParameters.searchInOther) { if (searchParameters.searchInOther) {
entry.getExtraFields().forEach { field -> entry.getExtraFields().forEach { field ->
if (field.name != OTP_FIELD if ( (field.name != OTP_FIELD && field.name != FIELD_RELYING_PARTY)
|| (field.name == OTP_FIELD && searchParameters.searchInOTP)) { || (searchParameters.searchInOTP && field.name == OTP_FIELD)
|| (searchParameters.searchInRelyingParty && field.name == FIELD_RELYING_PARTY)
) {
if (checkSearchQuery(field.protectedValue.toString(), searchParameters)) if (checkSearchQuery(field.protectedValue.toString(), searchParameters))
return true return true
} }

View File

@@ -37,6 +37,7 @@ class SearchParameters() : Parcelable{
var searchInExpired = false var searchInExpired = false
var searchInNotes = true var searchInNotes = true
var searchInOTP = false var searchInOTP = false
var searchInRelyingParty = false
var searchInOther = true var searchInOther = true
var searchInUUIDs = false var searchInUUIDs = false
var searchInTags = false var searchInTags = false

View File

@@ -22,10 +22,15 @@ package com.kunzisoft.keepass.model
import android.os.Parcel import android.os.Parcel
import android.os.ParcelUuid import android.os.ParcelUuid
import android.os.Parcelable import android.os.Parcelable
import com.kunzisoft.keepass.database.element.* import com.kunzisoft.keepass.database.element.Attachment
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.DateInstant
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.Tags
import com.kunzisoft.keepass.database.element.entry.AutoType import com.kunzisoft.keepass.database.element.entry.AutoType
import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.database.element.template.TemplateField import com.kunzisoft.keepass.database.element.template.TemplateField
import com.kunzisoft.keepass.model.EntryInfoPasskey.setPasskey
import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
@@ -33,7 +38,8 @@ import com.kunzisoft.keepass.utils.readBooleanCompat
import com.kunzisoft.keepass.utils.readListCompat import com.kunzisoft.keepass.utils.readListCompat
import com.kunzisoft.keepass.utils.readParcelableCompat import com.kunzisoft.keepass.utils.readParcelableCompat
import com.kunzisoft.keepass.utils.writeBooleanCompat import com.kunzisoft.keepass.utils.writeBooleanCompat
import java.util.* import java.util.Locale
import java.util.UUID
class EntryInfo : NodeInfo { class EntryInfo : NodeInfo {
@@ -113,6 +119,14 @@ class EntryInfo : NodeInfo {
return customFields.lastOrNull { it.name == label }?.protectedValue?.toString() ?: "" return customFields.lastOrNull { it.name == label }?.protectedValue?.toString() ?: ""
} }
fun addOrReplaceField(field: Field) {
customFields.lastOrNull { it.name == field.name }?.let {
it.apply {
protectedValue = field.protectedValue
}
} ?: customFields.add(field)
}
// Return true if modified // Return true if modified
private fun addUniqueField(field: Field, number: Int = 0) { private fun addUniqueField(field: Field, number: Int = 0) {
var sameName = false var sameName = false
@@ -226,6 +240,7 @@ class EntryInfo : NodeInfo {
} }
if (database?.allowEntryCustomFields() == true) { if (database?.allowEntryCustomFields() == true) {
// TODO Move in a dedicated creditcard class
val creditCard: CreditCard? = registerInfo.creditCard val creditCard: CreditCard? = registerInfo.creditCard
creditCard?.cardholder?.let { creditCard?.cardholder?.let {
addUniqueField(Field(TemplateField.LABEL_HOLDER, ProtectedString(false, it))) addUniqueField(Field(TemplateField.LABEL_HOLDER, ProtectedString(false, it)))
@@ -240,6 +255,9 @@ class EntryInfo : NodeInfo {
creditCard?.cvv?.let { creditCard?.cvv?.let {
addUniqueField(Field(TemplateField.LABEL_CVV, ProtectedString(true, it))) addUniqueField(Field(TemplateField.LABEL_CVV, ProtectedString(true, it)))
} }
registerInfo.passkey?.let {
setPasskey(it)
}
} }
} }

View File

@@ -0,0 +1,91 @@
package com.kunzisoft.keepass.model
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
object EntryInfoPasskey {
// field names from KeypassXC are used
private const val FIELD_USERNAME = "KPEX_PASSKEY_USERNAME"
private const val FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM"
private const val FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID"
private const val FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE"
const val FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY"
private const val PASSKEY_TAG = "Passkey"
fun EntryInfo.getPasskey(): Passkey? {
if (this.tags.toList().contains(PASSKEY_TAG).not()) {
return null
}
var username = ""
var privateKeyPem = ""
var credId = ""
var userHandle = ""
var relyingParty = ""
for (field in this.customFields) {
when (field.name) {
FIELD_USERNAME -> {
username = field.protectedValue.stringValue
}
FIELD_PRIVATE_KEY -> {
privateKeyPem = field.protectedValue.stringValue
}
FIELD_CREDENTIAL_ID -> {
credId = field.protectedValue.stringValue
}
FIELD_USER_HANDLE -> {
userHandle = field.protectedValue.stringValue
}
FIELD_RELYING_PARTY -> {
relyingParty = field.protectedValue.stringValue
}
}
}
return Passkey(
username = username,
displayName = this.getVisualTitle(),
privateKeyPem = privateKeyPem,
credentialId = credId,
userHandle = userHandle,
relyingParty = relyingParty
)
}
fun EntryInfo.setPasskey(passkey: Passkey) {
tags.put(PASSKEY_TAG)
title = passkey.displayName
username = passkey.username
url = passkey.relyingParty
addOrReplaceField(
Field(
FIELD_USERNAME,
ProtectedString(enableProtection = false, passkey.username)
)
)
addOrReplaceField(
Field(
FIELD_PRIVATE_KEY,
ProtectedString(enableProtection = true, passkey.privateKeyPem)
)
)
addOrReplaceField(
Field(
FIELD_CREDENTIAL_ID,
ProtectedString(enableProtection = true, passkey.credentialId)
)
)
addOrReplaceField(
Field(
FIELD_USER_HANDLE,
ProtectedString(enableProtection = true, passkey.userHandle)
)
)
addOrReplaceField(
Field(
FIELD_RELYING_PARTY,
ProtectedString(enableProtection = false, passkey.relyingParty)
)
)
}
}

View File

@@ -0,0 +1,14 @@
package com.kunzisoft.keepass.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Passkey(
val username: String,
val displayName: String,
val privateKeyPem: String,
val credentialId: String,
val userHandle: String,
val relyingParty: String,
): Parcelable

View File

@@ -4,15 +4,19 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import com.kunzisoft.keepass.utils.readParcelableCompat import com.kunzisoft.keepass.utils.readParcelableCompat
data class RegisterInfo(val searchInfo: SearchInfo, data class RegisterInfo(
val username: String?, val searchInfo: SearchInfo,
val password: String?, val username: String?,
val creditCard: CreditCard?): Parcelable { val password: String? = null,
val creditCard: CreditCard? = null,
val passkey: Passkey? = null
): Parcelable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readParcelableCompat() ?: SearchInfo(), parcel.readParcelableCompat() ?: SearchInfo(),
parcel.readString() ?: "", parcel.readString() ?: "",
parcel.readString() ?: "", parcel.readString() ?: "",
parcel.readParcelableCompat(),
parcel.readParcelableCompat()) { parcel.readParcelableCompat()) {
} }
@@ -21,6 +25,7 @@ data class RegisterInfo(val searchInfo: SearchInfo,
parcel.writeString(username) parcel.writeString(username)
parcel.writeString(password) parcel.writeString(password)
parcel.writeParcelable(creditCard, flags) parcel.writeParcelable(creditCard, flags)
parcel.writeParcelable(passkey, flags)
} }
override fun describeContents(): Int { override fun describeContents(): Int {

View File

@@ -4,6 +4,7 @@ import android.content.res.Resources
import android.net.Uri import android.net.Uri
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.ObjectNameResource import com.kunzisoft.keepass.utils.ObjectNameResource
import com.kunzisoft.keepass.utils.readBooleanCompat import com.kunzisoft.keepass.utils.readBooleanCompat
@@ -33,6 +34,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
get() { get() {
return if (webDomain == null) null else field return if (webDomain == null) null else field
} }
var relyingParty: String? = null
var otpString: String? = null var otpString: String? = null
constructor() constructor()
@@ -42,6 +44,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
applicationId = toCopy?.applicationId applicationId = toCopy?.applicationId
webDomain = toCopy?.webDomain webDomain = toCopy?.webDomain
webScheme = toCopy?.webScheme webScheme = toCopy?.webScheme
relyingParty = toCopy?.relyingParty
otpString = toCopy?.otpString otpString = toCopy?.otpString
} }
@@ -53,6 +56,8 @@ class SearchInfo : ObjectNameResource, Parcelable {
webDomain = if (readDomain.isNullOrEmpty()) null else readDomain webDomain = if (readDomain.isNullOrEmpty()) null else readDomain
val readScheme = parcel.readString() val readScheme = parcel.readString()
webScheme = if (readScheme.isNullOrEmpty()) null else readScheme webScheme = if (readScheme.isNullOrEmpty()) null else readScheme
val readRelyingParty = parcel.readString()
relyingParty = if (readRelyingParty.isNullOrEmpty()) null else readRelyingParty
val readOtp = parcel.readString() val readOtp = parcel.readString()
otpString = if (readOtp.isNullOrEmpty()) null else readOtp otpString = if (readOtp.isNullOrEmpty()) null else readOtp
} }
@@ -66,6 +71,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
parcel.writeString(applicationId ?: "") parcel.writeString(applicationId ?: "")
parcel.writeString(webDomain ?: "") parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "") parcel.writeString(webScheme ?: "")
parcel.writeString(relyingParty ?: "")
parcel.writeString(otpString ?: "") parcel.writeString(otpString ?: "")
} }
@@ -82,12 +88,55 @@ class SearchInfo : ObjectNameResource, Parcelable {
return applicationId == null return applicationId == null
&& webDomain == null && webDomain == null
&& webScheme == null && webScheme == null
&& relyingParty == null
&& otpString == null && otpString == null
} }
fun isASearchByDomain(): Boolean { private fun isASearchByDomain(): Boolean {
return toString() == webDomain && webDomain != null return toString() == webDomain && webDomain != null
} }
private fun isAPasskeySearch(): Boolean {
return toString() == relyingParty && relyingParty != null
}
fun buildSearchParameters(): SearchParameters {
return SearchParameters().apply {
if (isAPasskeySearch()) {
searchQuery = toString()
searchInTitles = false
searchInUsernames = false
searchInPasswords = false
searchInUrls = false
searchInNotes = false
searchInOTP = false
searchInOther = false
searchInUUIDs = false
searchInTags = false
searchInRelyingParty = true
searchInCurrentGroup = false
searchInSearchableGroup = false
searchInRecycleBin = false
searchInTemplates = false
} else {
searchQuery = toString()
searchInTitles = true
searchInUsernames = false
searchInPasswords = false
searchInUrls = true
searchByDomain = isASearchByDomain()
searchInNotes = true
searchInOTP = false
searchInOther = true
searchInUUIDs = false
searchInTags = false
searchInCurrentGroup = false
searchInSearchableGroup = true
searchInRecycleBin = false
searchInTemplates = false
}
}
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -97,6 +146,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
if (applicationId != other.applicationId) return false if (applicationId != other.applicationId) return false
if (webDomain != other.webDomain) return false if (webDomain != other.webDomain) return false
if (webScheme != other.webScheme) return false if (webScheme != other.webScheme) return false
if (relyingParty != other.relyingParty) return false
if (otpString != other.otpString) return false if (otpString != other.otpString) return false
return true return true
@@ -107,12 +157,13 @@ class SearchInfo : ObjectNameResource, Parcelable {
result = 31 * result + (applicationId?.hashCode() ?: 0) result = 31 * result + (applicationId?.hashCode() ?: 0)
result = 31 * result + (webDomain?.hashCode() ?: 0) result = 31 * result + (webDomain?.hashCode() ?: 0)
result = 31 * result + (webScheme?.hashCode() ?: 0) result = 31 * result + (webScheme?.hashCode() ?: 0)
result = 31 * result + (relyingParty?.hashCode() ?: 0)
result = 31 * result + (otpString?.hashCode() ?: 0) result = 31 * result + (otpString?.hashCode() ?: 0)
return result return result
} }
override fun toString(): String { override fun toString(): String {
return otpString ?: webDomain ?: applicationId ?: "" return otpString ?: webDomain ?: applicationId ?: relyingParty ?: ""
} }
companion object { companion object {