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

View File

@@ -23,7 +23,6 @@ import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -38,13 +37,10 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.viewModels
import androidx.appcompat.widget.Toolbar
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.BlendModeCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.activities.fragments.EntryFragment
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.adapters.TagsAdapter
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.node.NodeId
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.otp.OtpType
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.SetOTPDialogFragment
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.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.adapters.TemplatesSelectorAdapter
import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
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.element.Attachment
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.template.Template
import com.kunzisoft.keepass.education.EntryEditActivityEducation
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.model.AttachmentState
import com.kunzisoft.keepass.model.DataTime
import com.kunzisoft.keepass.model.EntryAttachmentState
@@ -376,18 +378,25 @@ class EntryEditActivity : DatabaseLockActivity(),
// Don't wait for saving if it's to provide autofill
mDatabase?.let { database ->
EntrySelectionHelper.doSpecialAction(intent,
{},
{},
{},
{
EntrySelectionHelper.doSpecialAction(
intent = intent,
defaultAction = {},
searchAction = {},
saveAction = {},
keyboardSelectionAction = {
entryValidatedForKeyboardSelection(database, entrySave.newEntry)
},
{ _, _ ->
autofillSelectionAction = { _, _ ->
entryValidatedForAutofillSelection(database, entrySave.newEntry)
},
{
autofillRegistrationAction = {
entryValidatedForAutofillRegistration(entrySave.newEntry)
},
passkeySelectionAction = {
entryValidatedForPasskeySelection(database, entrySave.newEntry)
},
passkeyRegistrationAction = {
entryValidatedForPasskeyRegistration(database, entrySave.newEntry)
}
)
}
@@ -430,25 +439,32 @@ class EntryEditActivity : DatabaseLockActivity(),
}
if (newNodes.size == 1) {
(newNodes[0] as? Entry?)?.let { entry ->
EntrySelectionHelper.doSpecialAction(intent,
{
EntrySelectionHelper.doSpecialAction(
intent = intent,
defaultAction = {
// Finish naturally
finishForEntryResult(entry)
},
{
searchAction = {
// Nothing when search retrieved
},
{
saveAction = {
entryValidatedForSave(entry)
},
{
keyboardSelectionAction = {
entryValidatedForKeyboardSelection(database, entry)
},
{ _, _ ->
autofillSelectionAction = { _, _ ->
entryValidatedForAutofillSelection(database, entry)
},
{
autofillRegistrationAction = {
entryValidatedForAutofillRegistration(entry)
},
passkeySelectionAction = {
entryValidatedForPasskeySelection(database, entry)
},
passkeyRegistrationAction = {
entryValidatedForPasskeyRegistration(database, entry)
}
)
}
@@ -488,10 +504,34 @@ class EntryEditActivity : DatabaseLockActivity(),
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()
}
override fun onResume() {
super.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) {
// Assign entry callback as a result
try {
val bundle = Bundle()
val bundle = buildEntryResult(entry)
val intentEntry = Intent()
bundle.putParcelable(ADD_OR_UPDATE_ENTRY_KEY, entry.nodeId)
intentEntry.putExtras(bundle)
setResult(Activity.RESULT_OK, intentEntry)
super.finish()
@@ -892,7 +937,7 @@ class EntryEditActivity : DatabaseLockActivity(),
if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) {
val intent = Intent(activity, EntryEditActivity::class.java)
intent.putExtra(KEY_PARENT, groupId)
AutofillHelper.startActivityForAutofillResult(
EntrySelectionHelper.startActivityForAutofillSelectionModeResult(
activity,
intent,
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)
*/
fun launchToUpdateForRegistration(context: Context,
database: ContextualDatabase,
activityResultLauncher: ActivityResultLauncher<Intent>?,
entryId: NodeId<UUID>,
registerInfo: RegisterInfo? = null) {
registerInfo: RegisterInfo?,
typeMode: TypeMode) {
if (database.loaded && !database.isReadOnly) {
if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) {
val intent = Intent(context, EntryEditActivity::class.java)
intent.putExtra(KEY_ENTRY, entryId)
EntrySelectionHelper.startActivityForRegistrationModeResult(
context,
activityResultLauncher,
intent,
registerInfo
registerInfo,
typeMode
)
}
}
@@ -928,16 +1000,20 @@ class EntryEditActivity : DatabaseLockActivity(),
*/
fun launchToCreateForRegistration(context: Context,
database: ContextualDatabase,
activityResultLauncher: ActivityResultLauncher<Intent>?,
groupId: NodeId<*>,
registerInfo: RegisterInfo? = null) {
registerInfo: RegisterInfo? = null,
typeMode: TypeMode) {
if (database.loaded && !database.isReadOnly) {
if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) {
val intent = Intent(context, EntryEditActivity::class.java)
intent.putExtra(KEY_PARENT, groupId)
EntrySelectionHelper.startActivityForRegistrationModeResult(
context,
activityResultLauncher,
intent,
registerInfo
registerInfo,
typeMode
)
}
}

View File

@@ -44,15 +44,16 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.snackbar.Snackbar
import com.kunzisoft.keepass.R
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.SpecialMode
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.activities.helpers.setOpenDocumentClickListener
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.adapters.FileDatabaseHistoryAdapter
import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction
import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.MainCredential
import com.kunzisoft.keepass.education.FileDatabaseSelectActivityEducation
@@ -99,10 +100,8 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
private var mExternalFileHelper: ExternalFileHelper? = null
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -299,7 +298,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
},
{ onCancelSpecialMode() },
{ onLaunchActivitySpecialMode() },
mAutofillActivityResultLauncher)
mCredentialActivityResultLauncher)
}
private fun launchGroupActivityIfLoaded(database: ContextualDatabase) {
@@ -309,7 +308,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
{ onValidateSpecialMode() },
{ onCancelSpecialMode() },
{ onLaunchActivitySpecialMode() },
mAutofillActivityResultLauncher)
mCredentialActivityResultLauncher)
}
}
@@ -493,23 +492,46 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(),
activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent,
searchInfo: SearchInfo? = null) {
AutofillHelper.startActivityForAutofillResult(activity,
EntrySelectionHelper.startActivityForAutofillSelectionModeResult(activity,
Intent(activity, FileDatabaseSelectActivity::class.java),
activityResultLauncher,
autofillComponent,
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
* -------------------------
*/
fun launchForRegistration(context: Context,
registerInfo: RegisterInfo? = null) {
EntrySelectionHelper.startActivityForRegistrationModeResult(context,
activityResultLauncher: ActivityResultLauncher<Intent>?,
registerInfo: RegisterInfo? = null,
typeMode: TypeMode) {
EntrySelectionHelper.startActivityForRegistrationModeResult(
context,
activityResultLauncher,
Intent(context, FileDatabaseSelectActivity::class.java),
registerInfo)
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.SortDialogFragment
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.SpecialMode
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.adapters.BreadcrumbAdapter
import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
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.MainCredential
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.search.SearchParameters
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.GroupInfo
import com.kunzisoft.keepass.model.RegisterInfo
@@ -264,10 +267,8 @@ class GroupActivity : DatabaseLockActivity(),
mGroupEditViewModel.selectIcon(icon)
}
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -484,59 +485,87 @@ class GroupActivity : DatabaseLockActivity(),
addNodeButtonView?.setAddEntryClickListener {
mDatabase?.let { database ->
mMainGroup?.let { currentGroup ->
EntrySelectionHelper.doSpecialAction(intent,
{
EntrySelectionHelper.doSpecialAction(
intent = intent,
defaultAction = {
mMainGroup?.nodeId?.let { currentParentGroupId ->
EntryEditActivity.launchToCreate(
this@GroupActivity,
database,
currentParentGroupId,
mEntryActivityResultLauncher
activity = this@GroupActivity,
database = database,
groupId = currentParentGroupId,
activityResultLauncher = mEntryActivityResultLauncher
)
}
},
{
searchAction = {
// Search not used
},
{ searchInfo ->
saveAction = { searchInfo ->
EntryEditActivity.launchToCreateForSave(
this@GroupActivity,
database,
currentGroup.nodeId,
searchInfo
context = this@GroupActivity,
database = database,
groupId = currentGroup.nodeId,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
},
{ searchInfo ->
keyboardSelectionAction = { searchInfo ->
EntryEditActivity.launchForKeyboardSelectionResult(
this@GroupActivity,
database,
currentGroup.nodeId,
searchInfo
context = this@GroupActivity,
database = database,
groupId = currentGroup.nodeId,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
},
{ searchInfo, autofillComponent ->
autofillSelectionAction = { searchInfo, autofillComponent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
EntryEditActivity.launchForAutofillResult(
this@GroupActivity,
database,
mAutofillActivityResultLauncher,
autofillComponent,
currentGroup.nodeId,
searchInfo
activity = this@GroupActivity,
database = database,
activityResultLauncher = mCredentialActivityResultLauncher,
autofillComponent = autofillComponent,
groupId = currentGroup.nodeId,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
} else {
onCancelSpecialMode()
}
},
{ searchInfo ->
autofillRegistrationAction = { registerInfo ->
EntryEditActivity.launchToCreateForRegistration(
this@GroupActivity,
database,
currentGroup.nodeId,
searchInfo
context = this@GroupActivity,
database = database,
activityResultLauncher = null,
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()
}
@@ -679,30 +708,40 @@ class GroupActivity : DatabaseLockActivity(),
when (actionTask) {
ACTION_DATABASE_UPDATE_ENTRY_TASK -> {
if (result.isSuccess) {
EntrySelectionHelper.doSpecialAction(intent,
{
EntrySelectionHelper.doSpecialAction(
intent = intent,
defaultAction = {
// Standard not used after task
},
{
searchAction = {
// Search not used
},
{
saveAction = {
// Save not used
},
{
keyboardSelectionAction = {
// Keyboard selection
entry?.let {
entrySelectedForKeyboardSelection(database, it)
}
},
{ _, _ ->
autofillSelectionAction = { _, _ ->
// Autofill selection
entry?.let {
entrySelectedForAutofillSelection(database, it)
}
},
{
autofillRegistrationAction = {
// Not use
},
passkeySelectionAction = {
// Passkey selection
entry?.let {
entrySelectedForPasskeySelection(database, it)
}
},
passkeyRegistrationAction = {
// TODO Passkey Registration
}
)
}
@@ -846,27 +885,28 @@ class GroupActivity : DatabaseLockActivity(),
Type.ENTRY -> try {
val entryVersioned = node as Entry
EntrySelectionHelper.doSpecialAction(intent,
{
EntrySelectionHelper.doSpecialAction(
intent = intent,
defaultAction = {
EntryActivity.launch(
this@GroupActivity,
database,
entryVersioned.nodeId,
mEntryActivityResultLauncher
activity = this@GroupActivity,
database = database,
entryId = entryVersioned.nodeId,
activityResultLauncher = mEntryActivityResultLauncher
)
// Do not reload group here
},
{
searchAction = {
// Nothing here, a search is simply performed
},
{ searchInfo ->
saveAction = { searchInfo ->
if (!database.isReadOnly) {
entrySelectedForSave(database, entryVersioned, searchInfo)
loadGroup()
} else
finish()
},
{ searchInfo ->
keyboardSelectionAction = { searchInfo ->
if (!database.isReadOnly
&& searchInfo != null
&& PreferencesUtil.isKeyboardSaveSearchInfoEnable(this@GroupActivity)
@@ -876,7 +916,7 @@ class GroupActivity : DatabaseLockActivity(),
entrySelectedForKeyboardSelection(database, entryVersioned)
loadGroup()
},
{ searchInfo, _ ->
autofillSelectionAction = { searchInfo, _ ->
if (!database.isReadOnly
&& searchInfo != null
&& PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity)
@@ -886,9 +926,39 @@ class GroupActivity : DatabaseLockActivity(),
entrySelectedForAutofillSelection(database, entryVersioned)
loadGroup()
},
{ registerInfo ->
autofillRegistrationAction = { registerInfo ->
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()
} else
finish()
@@ -934,18 +1004,33 @@ class GroupActivity : DatabaseLockActivity(),
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(
database: ContextualDatabase,
entry: Entry,
registerInfo: RegisterInfo?
activityResultLauncher: ActivityResultLauncher<Intent>?,
registerInfo: RegisterInfo?,
typeMode: TypeMode
) {
removeSearch()
// Registration to update the entry
EntryEditActivity.launchToUpdateForRegistration(
this@GroupActivity,
database,
entry.nodeId,
registerInfo
context = this@GroupActivity,
database = database,
activityResultLauncher = activityResultLauncher,
entryId = entry.nodeId,
registerInfo = registerInfo,
typeMode = typeMode
)
onLaunchActivitySpecialMode()
}
@@ -1569,19 +1654,19 @@ class GroupActivity : DatabaseLockActivity(),
* -------------------------
*/
@RequiresApi(api = Build.VERSION_CODES.O)
fun launchForAutofillResult(activity: AppCompatActivity,
fun launchForAutofillSelectionResult(activity: AppCompatActivity,
database: ContextualDatabase,
activityResultLaunch: ActivityResultLauncher<Intent>?,
activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent,
searchInfo: SearchInfo? = null,
autoSearch: Boolean = false) {
if (database.loaded) {
checkTimeAndBuildIntent(activity, null) { intent ->
intent.putExtra(AUTO_SEARCH_KEY, autoSearch)
AutofillHelper.startActivityForAutofillResult(
EntrySelectionHelper.startActivityForAutofillSelectionModeResult(
activity,
intent,
activityResultLaunch,
activityResultLauncher,
autofillComponent,
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
* -------------------------
*/
fun launchForRegistration(context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>?,
database: ContextualDatabase,
registerInfo: RegisterInfo? = null) {
registerInfo: RegisterInfo? = null,
typeMode: TypeMode) {
if (database.loaded && !database.isReadOnly) {
checkTimeAndBuildIntent(context, null) { intent ->
intent.putExtra(AUTO_SEARCH_KEY, false)
EntrySelectionHelper.startActivityForRegistrationModeResult(
context,
activityResultLauncher,
intent,
registerInfo
registerInfo,
typeMode
)
}
}
@@ -1619,9 +1732,10 @@ class GroupActivity : DatabaseLockActivity(),
onValidateSpecialMode: () -> Unit,
onCancelSpecialMode: () -> Unit,
onLaunchActivitySpecialMode: () -> Unit,
autofillActivityResultLauncher: ActivityResultLauncher<Intent>?) {
EntrySelectionHelper.doSpecialAction(activity.intent,
{
activityResultLauncher: ActivityResultLauncher<Intent>?) {
EntrySelectionHelper.doSpecialAction(
intent = activity.intent,
defaultAction = {
// Default action
launch(
activity,
@@ -1629,7 +1743,7 @@ class GroupActivity : DatabaseLockActivity(),
true
)
},
{ searchInfo ->
searchAction = { searchInfo ->
// Search action
if (database.loaded) {
launchForSearchResult(activity,
@@ -1642,7 +1756,7 @@ class GroupActivity : DatabaseLockActivity(),
onCancelSpecialMode()
}
},
{ searchInfo ->
saveAction = { searchInfo ->
// Save info
if (database.loaded) {
if (!database.isReadOnly) {
@@ -1664,12 +1778,13 @@ class GroupActivity : DatabaseLockActivity(),
}
}
},
{ searchInfo ->
keyboardSelectionAction = { searchInfo ->
// Keyboard selection
SearchHelper.checkAutoSearchInfo(activity,
database,
searchInfo,
{ _, items ->
SearchHelper.checkAutoSearchInfo(
context = activity,
database = database,
searchInfo = searchInfo,
onItemsFound = { _, items ->
MagikeyboardService.performSelection(
items,
{ entryInfo ->
@@ -1689,7 +1804,7 @@ class GroupActivity : DatabaseLockActivity(),
}
)
},
{
onItemNotFound = {
// Here no search info found, disable auto search
launchForKeyboardSelectionResult(activity,
database,
@@ -1697,34 +1812,36 @@ class GroupActivity : DatabaseLockActivity(),
false)
onLaunchActivitySpecialMode()
},
{
onDatabaseClosed = {
// Simply close if database not opened, normally not happened
onCancelSpecialMode()
}
)
},
{ searchInfo, autofillComponent ->
autofillSelectionAction = { searchInfo, autofillComponent ->
// Autofill selection
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
SearchHelper.checkAutoSearchInfo(activity,
database,
searchInfo,
{ openedDatabase, items ->
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
launchForAutofillResult(activity,
database,
autofillActivityResultLauncher,
autofillComponent,
searchInfo,
false)
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()
}
@@ -1733,27 +1850,36 @@ class GroupActivity : DatabaseLockActivity(),
onCancelSpecialMode()
}
},
{ registerInfo ->
autofillRegistrationAction = { registerInfo ->
// Autofill registration
if (!database.isReadOnly) {
SearchHelper.checkAutoSearchInfo(activity,
database,
registerInfo?.searchInfo,
{ _, _ ->
SearchHelper.checkAutoSearchInfo(
context = activity,
database = database,
searchInfo = registerInfo?.searchInfo,
onItemsFound = { _, _ ->
// No auto search, it's a registration
launchForRegistration(activity,
database,
registerInfo)
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(activity,
database,
registerInfo)
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()
}
@@ -1765,7 +1891,72 @@ class GroupActivity : DatabaseLockActivity(),
.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()
},
actionEntrySelection = {
launchForPasskeySelectionResult(
context = activity,
database = database,
searchInfo = searchInfo,
activityResultLauncher = activityResultLauncher
)
onLaunchActivitySpecialMode()
}
)
},
onItemNotFound = {
// Here no search info found, disable auto search
launchForPasskeySelectionResult(
context = activity,
database = database,
searchInfo = searchInfo,
activityResultLauncher = activityResultLauncher
)
onLaunchActivitySpecialMode()
},
onDatabaseClosed = {
// Simply close if database not opened, normally not happened
onCancelSpecialMode()
}
)
} else {
onCancelSpecialMode()
}
},
passkeyRegistrationAction = { registerInfo ->
// Passkey registration
if (!database.isReadOnly) {
launchForRegistration(
context = activity,
activityResultLauncher = activityResultLauncher,
database = database,
registerInfo = registerInfo,
typeMode = TypeMode.PASSKEY
)
onLaunchActivitySpecialMode()
} else {
Toast.makeText(activity.applicationContext,
R.string.autofill_read_only_save,
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.kunzisoft.keepass.R
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.SpecialMode
import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
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.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.MainCredential
import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException
@@ -113,10 +114,8 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
private var mReadOnly: Boolean = false
private var mForceReadOnly: Boolean = false
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -395,7 +394,7 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
{ onValidateSpecialMode() },
{ onCancelSpecialMode() },
{ onLaunchActivitySpecialMode() },
mAutofillActivityResultLauncher
mCredentialActivityResultLauncher
)
}
}
@@ -806,14 +805,14 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
@RequiresApi(api = Build.VERSION_CODES.O)
@Throws(FileNotFoundException::class)
fun launchForAutofillResult(activity: AppCompatActivity,
activityResultLauncher: ActivityResultLauncher<Intent>?,
databaseFile: Uri,
keyFile: Uri?,
hardwareKey: HardwareKey?,
activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent,
searchInfo: SearchInfo?) {
buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent ->
AutofillHelper.startActivityForAutofillResult(
EntrySelectionHelper.startActivityForAutofillSelectionModeResult(
activity,
intent,
activityResultLauncher,
@@ -824,19 +823,49 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
/*
* -------------------------
* Registration Launch
* Passkey Launch
* -------------------------
*/
fun launchForRegistration(activity: Activity,
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Throws(FileNotFoundException::class)
fun launchForPasskeyResult(activity: Activity,
activityResultLauncher: ActivityResultLauncher<Intent>?,
databaseFile: Uri,
keyFile: Uri?,
hardwareKey: HardwareKey?,
registerInfo: RegisterInfo?) {
searchInfo: SearchInfo?) {
buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent ->
EntrySelectionHelper.startActivityForRegistrationModeResult(
EntrySelectionHelper.startActivityForPasskeySelectionModeResult(
activity,
intent,
registerInfo)
activityResultLauncher,
searchInfo
)
}
}
/*
* -------------------------
* Registration Launch
* -------------------------
*/
fun launchForRegistration(
activity: Activity,
activityResultLauncher: ActivityResultLauncher<Intent>?,
databaseFile: Uri,
keyFile: Uri?,
hardwareKey: HardwareKey?,
typeMode: TypeMode,
registerInfo: RegisterInfo?
) {
buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent ->
EntrySelectionHelper.startActivityForRegistrationModeResult(
context = activity,
activityResultLauncher = activityResultLauncher,
intent = intent,
typeMode = typeMode,
registerInfo = registerInfo
)
}
}
@@ -852,71 +881,101 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu
fileNoFoundAction: (exception: FileNotFoundException) -> Unit,
onCancelSpecialMode: () -> Unit,
onLaunchActivitySpecialMode: () -> Unit,
autofillActivityResultLauncher: ActivityResultLauncher<Intent>?) {
activityResultLauncher: ActivityResultLauncher<Intent>?) {
try {
EntrySelectionHelper.doSpecialAction(activity.intent,
{
EntrySelectionHelper.doSpecialAction(
intent = activity.intent,
defaultAction = {
launch(
activity,
databaseUri,
keyFile,
hardwareKey
activity = activity,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey
)
},
{ searchInfo -> // Search Action
searchAction = { searchInfo ->
launchForSearchResult(
activity,
databaseUri,
keyFile,
hardwareKey,
searchInfo
activity = activity,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
},
{ searchInfo -> // Save Action
saveAction = { searchInfo ->
launchForSaveResult(
activity,
databaseUri,
keyFile,
hardwareKey,
searchInfo
activity = activity,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
},
{ searchInfo -> // Keyboard Selection Action
keyboardSelectionAction = { searchInfo ->
launchForKeyboardResult(
activity,
databaseUri,
keyFile,
hardwareKey,
searchInfo
activity = activity,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
},
{ searchInfo, autofillComponent -> // Autofill Selection Action
autofillSelectionAction = { searchInfo, autofillComponent ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
launchForAutofillResult(
activity,
databaseUri,
keyFile,
hardwareKey,
autofillActivityResultLauncher,
autofillComponent,
searchInfo
activity = activity,
activityResultLauncher = activityResultLauncher,
databaseFile = databaseUri,
keyFile = keyFile,
hardwareKey = hardwareKey,
autofillComponent = autofillComponent,
searchInfo = searchInfo
)
onLaunchActivitySpecialMode()
} else {
onCancelSpecialMode()
}
},
{ registerInfo -> // Registration Action
autofillRegistrationAction = { registerInfo ->
launchForRegistration(
activity,
databaseUri,
keyFile,
hardwareKey,
registerInfo
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()
}

View File

@@ -36,8 +36,8 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.adapters.NodesAdapter
import com.kunzisoft.keepass.database.ContextualDatabase
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.activities.dialogs.DeleteNodesDialogFragment
import com.kunzisoft.keepass.activities.dialogs.PasswordEncodingDialogFragment
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.MainCredential
import com.kunzisoft.keepass.database.element.Entry

View File

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

View File

@@ -17,17 +17,32 @@
* 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.Intent
import android.graphics.drawable.Icon
import android.os.Build
import com.kunzisoft.keepass.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import android.util.Log
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.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.getParcelableExtraCompat
import com.kunzisoft.keepass.utils.putEnumExtra
object EntrySelectionHelper {
@@ -37,6 +52,33 @@ object EntrySelectionHelper {
private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_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,
intent: Intent,
searchInfo: SearchInfo) {
@@ -66,15 +108,52 @@ object EntrySelectionHelper {
context.startActivity(intent)
}
fun startActivityForRegistrationModeResult(context: Context,
/**
* Utility method to start an activity with an Autofill for result
*/
@RequiresApi(Build.VERSION_CODES.O)
fun startActivityForAutofillSelectionModeResult(
context: Context,
intent: Intent,
registerInfo: RegisterInfo?) {
addSpecialModeInIntent(intent, SpecialMode.REGISTRATION)
// At the moment, only autofill for registration
activityResultLauncher: ActivityResultLauncher<Intent>?,
autofillComponent: AutofillComponent,
searchInfo: SearchInfo?
) {
addSpecialModeInIntent(intent, SpecialMode.SELECTION)
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)
if (activityResultLauncher == null) {
intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
}
context?.startActivity(intent) ?: activityResultLauncher?.launch(intent) ?:
throw IllegalStateException("At least Context or ActivityResultLauncher must not be null")
}
fun addSearchInfoInIntent(intent: Intent, searchInfo: SearchInfo?) {
@@ -103,8 +182,13 @@ object EntrySelectionHelper {
}
fun addSpecialModeInIntent(intent: Intent, specialMode: SpecialMode) {
// TODO Replace by Intent.addSpecialMode
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 {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -131,6 +215,17 @@ object EntrySelectionHelper {
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,
defaultAction: () -> Unit,
searchAction: (searchInfo: SearchInfo) -> Unit,
@@ -138,7 +233,9 @@ object EntrySelectionHelper {
keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit,
autofillSelectionAction: (searchInfo: SearchInfo?,
autofillComponent: AutofillComponent) -> Unit,
autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) {
autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit,
passkeySelectionAction: (searchInfo: SearchInfo?) -> Unit,
passkeyRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) {
when (retrieveSpecialModeFromIntent(intent)) {
SpecialMode.DEFAULT -> {
@@ -186,6 +283,7 @@ object EntrySelectionHelper {
defaultAction.invoke()
}
TypeMode.MAGIKEYBOARD -> keyboardSelectionAction.invoke(searchInfo)
TypeMode.PASSKEY -> passkeySelectionAction.invoke(searchInfo)
else -> {
// In this case, error
removeModesFromIntent(intent)
@@ -202,10 +300,59 @@ object EntrySelectionHelper {
}
SpecialMode.REGISTRATION -> {
val registerInfo: RegisterInfo? = retrieveRegisterInfoFromIntent(intent)
if (!isIntentSenderMode(
specialMode = retrieveSpecialModeFromIntent(intent),
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 {
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/>.
*
*/
package com.kunzisoft.keepass.activities
package com.kunzisoft.keepass.credentialprovider.activity
import android.app.Activity
import android.app.PendingIntent
@@ -30,13 +30,17 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.RequiresApi
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity
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.autofill.AutofillComponent
import com.kunzisoft.keepass.autofill.AutofillHelper
import com.kunzisoft.keepass.autofill.CompatInlineSuggestionsRequest
import com.kunzisoft.keepass.autofill.KeeAutofillService
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
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.autofill.CompatInlineSuggestionsRequest
import com.kunzisoft.keepass.credentialprovider.autofill.KeeAutofillService
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.model.RegisterInfo
@@ -48,10 +52,8 @@ import com.kunzisoft.keepass.utils.getParcelableExtraCompat
@RequiresApi(api = Build.VERSION_CODES.O)
class AutofillLauncherActivity : DatabaseModeActivity() {
private var mAutofillActivityResultLauncher: ActivityResultLauncher<Intent>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
AutofillHelper.buildActivityResultLauncher(this, true)
else null
private var mCredentialActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher(lockDatabase = true)
override fun applyCustomStyle(): Boolean {
return false
@@ -72,7 +74,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
// To pass extra inline request
var compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
compatInlineSuggestionsRequest = bundle.getParcelableCompat(KEY_INLINE_SUGGESTION)
compatInlineSuggestionsRequest = bundle.getParcelableCompat(
KEY_INLINE_SUGGESTION
)
}
// Build search param
bundle.getParcelableCompat<SearchInfo>(KEY_SEARCH_INFO)?.let { searchInfo ->
@@ -102,7 +106,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
}
SpecialMode.REGISTRATION -> {
// To register info
val registerInfo = intent.getParcelableExtraCompat<RegisterInfo>(KEY_REGISTER_INFO)
val registerInfo = intent.getParcelableExtraCompat<RegisterInfo>(
KEY_REGISTER_INFO
)
val searchInfo = SearchInfo(registerInfo?.searchInfo)
WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain ->
searchInfo.webDomain = concreteWebDomain
@@ -134,29 +140,34 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
finish()
} else {
// If database is open
SearchHelper.checkAutoSearchInfo(this,
database,
searchInfo,
{ openedDatabase, items ->
SearchHelper.checkAutoSearchInfo(
context = this,
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, items ->
// Items found
AutofillHelper.buildResponseAndSetResult(this, openedDatabase, items)
finish()
},
{ openedDatabase ->
onItemNotFound = { openedDatabase ->
// Show the database UI to select the entry
GroupActivity.launchForAutofillResult(this,
GroupActivity.launchForAutofillSelectionResult(
this,
openedDatabase,
mAutofillActivityResultLauncher,
mCredentialActivityResultLauncher,
autofillComponent,
searchInfo,
false)
false
)
},
{
onDatabaseClosed = {
// If database not open
FileDatabaseSelectActivity.launchForAutofillResult(this,
mAutofillActivityResultLauncher,
FileDatabaseSelectActivity.launchForAutofillResult(
this,
mCredentialActivityResultLauncher,
autofillComponent,
searchInfo)
searchInfo
)
}
)
}
@@ -174,33 +185,46 @@ class AutofillLauncherActivity : DatabaseModeActivity() {
setResult(Activity.RESULT_CANCELED)
} else {
val readOnly = database?.isReadOnly != false
SearchHelper.checkAutoSearchInfo(this,
database,
searchInfo,
{ openedDatabase, _ ->
SearchHelper.checkAutoSearchInfo(
context = this,
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, _ ->
if (!readOnly) {
// Show the database UI to select the entry
GroupActivity.launchForRegistration(this,
openedDatabase,
registerInfo)
GroupActivity.launchForRegistration(
context = this,
activityResultLauncher = null, // TODO Autofill result launcher #765
database = openedDatabase,
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL
)
} else {
showReadOnlySaveMessage()
}
},
{ openedDatabase ->
onItemNotFound = { openedDatabase ->
if (!readOnly) {
// Show the database UI to select the entry
GroupActivity.launchForRegistration(this,
openedDatabase,
registerInfo)
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(this,
registerInfo)
FileDatabaseSelectActivity.launchForRegistration(
context = this,
activityResultLauncher = null, // TODO Autofill result launcher #765
registerInfo = registerInfo,
typeMode = TypeMode.AUTOFILL
)
}
)
}

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/>.
*
*/
package com.kunzisoft.keepass.activities
package com.kunzisoft.keepass.credentialprovider.activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.core.net.toUri
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity
import com.kunzisoft.keepass.activities.GroupActivity
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.helper.SearchHelper
import com.kunzisoft.keepass.magikeyboard.MagikeyboardService
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.KeyboardUtil.isKeyboardActivatedInSettings
import com.kunzisoft.keepass.utils.getParcelableCompat
import com.kunzisoft.keepass.utils.WebDomain
import com.kunzisoft.keepass.utils.getParcelableCompat
/**
* Activity to search or select entry in database,
@@ -73,7 +75,7 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() {
if (OtpEntryFields.isOTPUri(extra))
otpString = extra
else
sharedWebDomain = Uri.parse(extra).host
sharedWebDomain = extra.toUri().host
}
}
launchSelection(database, sharedWebDomain, otpString)
@@ -121,10 +123,11 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() {
// If database is open
val readOnly = database?.isReadOnly != false
SearchHelper.checkAutoSearchInfo(this,
database,
searchInfo,
{ openedDatabase, items ->
SearchHelper.checkAutoSearchInfo(
context = this,
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, items ->
// Items found
if (searchInfo.otpString != null) {
if (!readOnly) {
@@ -132,7 +135,8 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() {
this,
openedDatabase,
searchInfo,
false)
false
)
} else {
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
@@ -150,27 +154,33 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() {
)
},
{ autoSearch ->
GroupActivity.launchForKeyboardSelectionResult(this,
GroupActivity.launchForKeyboardSelectionResult(
this,
openedDatabase,
searchInfo,
autoSearch)
autoSearch
)
}
)
} else {
GroupActivity.launchForSearchResult(this,
GroupActivity.launchForSearchResult(
this,
openedDatabase,
searchInfo,
true)
true
)
}
},
{ openedDatabase ->
onItemNotFound = { openedDatabase ->
// Show the database UI to select the entry
if (searchInfo.otpString != null) {
if (!readOnly) {
GroupActivity.launchForSaveResult(this,
GroupActivity.launchForSaveResult(
this,
openedDatabase,
searchInfo,
false)
false
)
} else {
Toast.makeText(applicationContext,
R.string.autofill_read_only_save,
@@ -178,28 +188,38 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() {
.show()
}
} else if (searchShareForMagikeyboard) {
GroupActivity.launchForKeyboardSelectionResult(this,
GroupActivity.launchForKeyboardSelectionResult(
this,
openedDatabase,
searchInfo,
false)
false
)
} else {
GroupActivity.launchForSearchResult(this,
GroupActivity.launchForSearchResult(
this,
openedDatabase,
searchInfo,
false)
false
)
}
},
{
onDatabaseClosed = {
// If database not open
if (searchInfo.otpString != null) {
FileDatabaseSelectActivity.launchForSaveResult(this,
searchInfo)
FileDatabaseSelectActivity.launchForSaveResult(
this,
searchInfo
)
} else if (searchShareForMagikeyboard) {
FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this,
searchInfo)
FileDatabaseSelectActivity.launchForKeyboardSelectionResult(
this,
searchInfo
)
} else {
FileDatabaseSelectActivity.launchForSearchResult(this,
searchInfo)
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

View File

@@ -17,7 +17,7 @@
* 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.app.Activity
@@ -40,17 +40,13 @@ import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import android.widget.Toast
import android.widget.inline.InlinePresentationSpec
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
import androidx.core.content.ContextCompat
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.AutofillLauncherActivity
import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.SpecialMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon
import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.element.icon.IconImage
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.settings.AutofillSettingsActivity
import com.kunzisoft.keepass.settings.PreferencesUtil
import com.kunzisoft.keepass.utils.LOCK_ACTION
import com.kunzisoft.keepass.utils.getParcelableExtraCompat
import kotlin.math.min
@@ -294,23 +289,6 @@ object AutofillHelper {
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")
@RequiresApi(Build.VERSION_CODES.R)
private fun buildInlinePresentationForEntry(context: Context,
@@ -353,7 +331,7 @@ object AutofillHelper {
Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply {
setTintBlendMode(BlendMode.DST)
})
buildIconFromEntry(context, database, entryInfo)?.let { icon ->
entryInfo.buildIcon(context, database)?.let { icon ->
setEndIcon(icon.apply {
setTintBlendMode(BlendMode.DST)
})
@@ -534,7 +512,9 @@ object AutofillHelper {
StructureParser(structure).parse()?.let { result ->
// New Response
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) {
Toast.makeText(activity.applicationContext, R.string.autofill_inline_suggestions_keyboard, Toast.LENGTH_SHORT).show()
}
@@ -558,45 +538,14 @@ object AutofillHelper {
}
}
fun buildActivityResultLauncher(activity: AppCompatActivity,
lockDatabase: Boolean = false): ActivityResultLauncher<Intent> {
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)
fun Intent.addAutofillComponent(context: Context, autofillComponent: AutofillComponent) {
this.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& PreferencesUtil.isAutofillInlineSuggestionsEnable(activity)) {
&& PreferencesUtil.isAutofillInlineSuggestionsEnable(context)) {
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

View File

@@ -17,7 +17,7 @@
* 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.os.Build

View File

@@ -17,7 +17,7 @@
* 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.app.PendingIntent
@@ -43,8 +43,8 @@ import androidx.annotation.RequiresApi
import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.AutofillLauncherActivity
import com.kunzisoft.keepass.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW
import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity
import com.kunzisoft.keepass.credentialprovider.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.DatabaseTaskProvider
import com.kunzisoft.keepass.database.helper.SearchHelper
@@ -143,21 +143,24 @@ class KeeAutofillService : AutofillService() {
parseResult: StructureParser.Result,
inlineSuggestionsRequest: CompatInlineSuggestionsRequest?,
callback: FillCallback) {
SearchHelper.checkAutoSearchInfo(this,
database,
searchInfo,
{ openedDatabase, items ->
SearchHelper.checkAutoSearchInfo(
context = this,
database = database,
searchInfo = searchInfo,
onItemsFound = { openedDatabase, items ->
callback.onSuccess(
AutofillHelper.buildResponse(this, openedDatabase,
items, parseResult, inlineSuggestionsRequest)
AutofillHelper.buildResponse(
this, openedDatabase,
items, parseResult, inlineSuggestionsRequest
)
)
},
{ openedDatabase ->
onItemNotFound = { openedDatabase ->
// Show UI if no search result
showUIForEntrySelection(parseResult, openedDatabase,
searchInfo, inlineSuggestionsRequest, callback)
},
{
onDatabaseClosed = {
// Show UI if database not open
showUIForEntrySelection(parseResult, null,
searchInfo, inlineSuggestionsRequest, callback)
@@ -385,19 +388,21 @@ class KeeAutofillService : AutofillService() {
// Show UI to save data
val registerInfo = RegisterInfo(
SearchInfo().apply {
searchInfo = SearchInfo().apply {
applicationId = parseResult.applicationId
webDomain = parseResult.webDomain
webScheme = parseResult.webScheme
},
parseResult.usernameValue?.textValue?.toString(),
parseResult.passwordValue?.textValue?.toString(),
username = parseResult.usernameValue?.textValue?.toString(),
password = parseResult.passwordValue?.textValue?.toString(),
creditCard =
CreditCard(
parseResult.creditCardHolder,
parseResult.creditCardNumber,
expiration,
parseResult.cardVerificationValue
))
)
)
// TODO Callback in each activity #765
//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
* 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.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.
*/
package com.kunzisoft.keepass.magikeyboard;
package com.kunzisoft.keepass.credentialprovider.magikeyboard;
import android.content.Context;
import android.content.res.Resources;

View File

@@ -14,14 +14,14 @@
* 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.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP;
import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP_ALT;
import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_BACK_KEYBOARD;
import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD;
import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY;
import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT;
import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP;
import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP_ALT;
import android.content.Context;
import android.content.res.TypedArray;
@@ -52,7 +52,7 @@ import android.widget.TextView;
import androidx.annotation.RequiresApi;
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.HashMap;

View File

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

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

View File

@@ -1,12 +1,12 @@
package com.kunzisoft.keepass.credentialprovider.util
package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.annotation.SuppressLint
import androidx.credentials.webauthn.Cbor
import com.kunzisoft.encrypt.HashManager
import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialCreationOptions
import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialRequestOptions
import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Decode
import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Encode
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions
import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Decode
import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode
import org.json.JSONArray
import org.json.JSONObject
@@ -245,7 +245,5 @@ class JsonHelper {
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 androidx.credentials.provider.CallingAppInfo
@@ -9,13 +9,12 @@ class OriginHelper {
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 {
it.readText()
}
// for trusted browsers like Chrome and Firefox
val origin = callingAppInfo.getOrigin(privilegedAllowlist)?.removeSuffix("/")
return origin
return callingAppInfo?.getOrigin(privilegedAllowlist)?.removeSuffix("/")
}
}

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]
*/
fun checkAutoSearchInfo(context: Context,
fun checkAutoSearchInfo(
context: Context,
database: ContextualDatabase?,
searchInfo: SearchInfo?,
onItemsFound: (openedDatabase: ContextualDatabase,
items: List<EntryInfo>) -> Unit,
onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit,
onDatabaseClosed: () -> Unit) {
onDatabaseClosed: () -> Unit
) {
if (database == null || !database.loaded) {
onDatabaseClosed.invoke()
} else if (TimeoutHelper.checkTime(context)) {
@@ -59,8 +61,7 @@ object SearchHelper {
&& !searchInfo.containsOnlyNullValues()) {
// If search provide results
database.createVirtualGroupFromSearchInfo(
searchInfo.toString(),
searchInfo.isASearchByDomain(),
searchInfo,
MAX_SEARCH_ENTRY
)?.let { searchGroup ->
if (searchGroup.numberOfChildEntries > 0) {

View File

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

View File

@@ -27,7 +27,7 @@ import android.util.Log
import android.widget.Toast
import androidx.preference.PreferenceManager
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.settings.PreferencesUtil
import com.kunzisoft.keepass.timeout.TimeoutHelper

View File

@@ -32,7 +32,7 @@ import android.util.Log
import androidx.core.content.ContextCompat
import com.kunzisoft.keepass.R
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.KeyboardEntryNotificationService
import com.kunzisoft.keepass.settings.PreferencesUtil

View File

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

View File

@@ -93,7 +93,7 @@
android:maxLines="1"
android:ellipsize="end"
android:textColor="@color/grey_blue_slighter"/>
<com.kunzisoft.keepass.magikeyboard.KeyboardView
<com.kunzisoft.keepass.credentialprovider.magikeyboard.KeyboardView
android:id="@+id/magikeyboard_view"
style="@style/KeepassDXStyle.Keyboard"
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_templates_title">Hide templates</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_usage_biometric_prompt_subtitle">for %1$s</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">Passkey</string>
<string name="passkey_service_name">KeePassDX Credential Provider</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_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_account_name">KeePassDX Database Locked</string>
</resources>

View File

@@ -1,5 +1,6 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
android {
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.SearchParameters
import com.kunzisoft.keepass.hardware.HardwareKey
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.tasks.ProgressTaskUpdater
import com.kunzisoft.keepass.utils.SingletonHolder
import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorInt
@@ -885,28 +886,15 @@ open class Database {
}
fun createVirtualGroupFromSearchInfo(
searchInfoString: String,
searchInfoByDomain: Boolean,
searchInfo: SearchInfo,
max: Int = Integer.MAX_VALUE
): Group? {
return mSearchHelper.createVirtualGroupWithSearchResult(this,
SearchParameters().apply {
searchQuery = searchInfoString
searchInTitles = true
searchInUsernames = false
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)
return mSearchHelper.createVirtualGroupWithSearchResult(
database = this,
searchParameters = searchInfo.buildSearchParameters(),
fromGroup = null,
max = max
)
}
val tagPool: Tags

View File

@@ -49,6 +49,10 @@ class Tags: Parcelable {
}
}
fun contains(tag: String): Boolean {
return mTags.contains(tag)
}
fun isEmpty(): Boolean {
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.node.NodeHandler
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.utils.UuidUtil
import com.kunzisoft.keepass.utils.inTheSameDomainAs
@@ -171,8 +172,10 @@ class SearchHelper {
}
if (searchParameters.searchInOther) {
entry.getExtraFields().forEach { field ->
if (field.name != OTP_FIELD
|| (field.name == OTP_FIELD && searchParameters.searchInOTP)) {
if ( (field.name != OTP_FIELD && field.name != FIELD_RELYING_PARTY)
|| (searchParameters.searchInOTP && field.name == OTP_FIELD)
|| (searchParameters.searchInRelyingParty && field.name == FIELD_RELYING_PARTY)
) {
if (checkSearchQuery(field.protectedValue.toString(), searchParameters))
return true
}

View File

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

View File

@@ -22,10 +22,15 @@ package com.kunzisoft.keepass.model
import android.os.Parcel
import android.os.ParcelUuid
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.security.ProtectedString
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.OtpEntryFields
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.readParcelableCompat
import com.kunzisoft.keepass.utils.writeBooleanCompat
import java.util.*
import java.util.Locale
import java.util.UUID
class EntryInfo : NodeInfo {
@@ -113,6 +119,14 @@ class EntryInfo : NodeInfo {
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
private fun addUniqueField(field: Field, number: Int = 0) {
var sameName = false
@@ -226,6 +240,7 @@ class EntryInfo : NodeInfo {
}
if (database?.allowEntryCustomFields() == true) {
// TODO Move in a dedicated creditcard class
val creditCard: CreditCard? = registerInfo.creditCard
creditCard?.cardholder?.let {
addUniqueField(Field(TemplateField.LABEL_HOLDER, ProtectedString(false, it)))
@@ -240,6 +255,9 @@ class EntryInfo : NodeInfo {
creditCard?.cvv?.let {
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 com.kunzisoft.keepass.utils.readParcelableCompat
data class RegisterInfo(val searchInfo: SearchInfo,
data class RegisterInfo(
val searchInfo: SearchInfo,
val username: String?,
val password: String?,
val creditCard: CreditCard?): Parcelable {
val password: String? = null,
val creditCard: CreditCard? = null,
val passkey: Passkey? = null
): Parcelable {
constructor(parcel: Parcel) : this(
parcel.readParcelableCompat() ?: SearchInfo(),
parcel.readString() ?: "",
parcel.readString() ?: "",
parcel.readParcelableCompat(),
parcel.readParcelableCompat()) {
}
@@ -21,6 +25,7 @@ data class RegisterInfo(val searchInfo: SearchInfo,
parcel.writeString(username)
parcel.writeString(password)
parcel.writeParcelable(creditCard, flags)
parcel.writeParcelable(passkey, flags)
}
override fun describeContents(): Int {

View File

@@ -4,6 +4,7 @@ import android.content.res.Resources
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.ObjectNameResource
import com.kunzisoft.keepass.utils.readBooleanCompat
@@ -33,6 +34,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
get() {
return if (webDomain == null) null else field
}
var relyingParty: String? = null
var otpString: String? = null
constructor()
@@ -42,6 +44,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
applicationId = toCopy?.applicationId
webDomain = toCopy?.webDomain
webScheme = toCopy?.webScheme
relyingParty = toCopy?.relyingParty
otpString = toCopy?.otpString
}
@@ -53,6 +56,8 @@ class SearchInfo : ObjectNameResource, Parcelable {
webDomain = if (readDomain.isNullOrEmpty()) null else readDomain
val readScheme = parcel.readString()
webScheme = if (readScheme.isNullOrEmpty()) null else readScheme
val readRelyingParty = parcel.readString()
relyingParty = if (readRelyingParty.isNullOrEmpty()) null else readRelyingParty
val readOtp = parcel.readString()
otpString = if (readOtp.isNullOrEmpty()) null else readOtp
}
@@ -66,6 +71,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
parcel.writeString(applicationId ?: "")
parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "")
parcel.writeString(relyingParty ?: "")
parcel.writeString(otpString ?: "")
}
@@ -82,13 +88,56 @@ class SearchInfo : ObjectNameResource, Parcelable {
return applicationId == null
&& webDomain == null
&& webScheme == null
&& relyingParty == null
&& otpString == null
}
fun isASearchByDomain(): Boolean {
private fun isASearchByDomain(): Boolean {
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 {
if (this === other) return true
if (other !is SearchInfo) return false
@@ -97,6 +146,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
if (applicationId != other.applicationId) return false
if (webDomain != other.webDomain) return false
if (webScheme != other.webScheme) return false
if (relyingParty != other.relyingParty) return false
if (otpString != other.otpString) return false
return true
@@ -107,12 +157,13 @@ class SearchInfo : ObjectNameResource, Parcelable {
result = 31 * result + (applicationId?.hashCode() ?: 0)
result = 31 * result + (webDomain?.hashCode() ?: 0)
result = 31 * result + (webScheme?.hashCode() ?: 0)
result = 31 * result + (relyingParty?.hashCode() ?: 0)
result = 31 * result + (otpString?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return otpString ?: webDomain ?: applicationId ?: ""
return otpString ?: webDomain ?: applicationId ?: relyingParty ?: ""
}
companion object {