feat: Add app Signature

This commit is contained in:
J-Jamet
2025-08-26 17:08:23 +02:00
parent 9985c6065d
commit 5bd866e104
18 changed files with 599 additions and 219 deletions

View File

@@ -63,15 +63,17 @@ 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.credentialprovider.EntrySelectionHelper
import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity
import com.kunzisoft.keepass.adapters.BreadcrumbAdapter
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.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.MainCredential
import com.kunzisoft.keepass.database.element.DateInstant
@@ -85,8 +87,6 @@ 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.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
@@ -1046,11 +1046,10 @@ class GroupActivity : DatabaseLockActivity(),
raw = true,
removeTemplateConfiguration = false
)
val modification = entryInfo.saveSearchInfo(database, searchInfo)
// TODO Transform SearchInfo in RegisterInfo
entryInfo.saveSearchInfo(database, searchInfo)
newEntry.setEntryInfo(database, entryInfo)
if (modification) {
updateEntry(entry, newEntry)
}
updateEntry(entry, newEntry)
}
private fun finishNodeAction() {

View File

@@ -31,6 +31,7 @@ import androidx.annotation.RequiresApi
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import androidx.lifecycle.lifecycleScope
import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity
import com.kunzisoft.keepass.activities.GroupActivity
import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
@@ -43,12 +44,14 @@ import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredential
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addNodeId
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addOriginAppInfo
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addSearchInfo
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.checkSecurity
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveOriginAppInfo
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters
@@ -56,9 +59,13 @@ import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retri
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.OriginApp
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.InvalidObjectException
import java.util.UUID
@@ -69,6 +76,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
private var mCreationParameters: PublicKeyCredentialCreationParameters? = null
private var mPasskey: Passkey? = null
private var mSearchInfo: SearchInfo = SearchInfo()
private var mOriginApp: OriginApp = OriginApp()
private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher(
@@ -136,17 +144,23 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mSearchInfo = intent.retrieveSearchInfo() ?: mSearchInfo
mOriginApp = intent.retrieveOriginAppInfo() ?: mOriginApp
}
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
super.onDatabaseRetrieved(database)
try {
lifecycleScope.launch(CoroutineExceptionHandler { _, e ->
Log.e(TAG, "Passkey launch error", e)
Toast.makeText(this, e.localizedMessage, Toast.LENGTH_LONG).show()
setResult(RESULT_CANCELED)
finish()
}) {
val nodeId = intent.retrieveNodeId()
checkSecurity(intent, nodeId)
when (mSpecialMode) {
SpecialMode.SELECTION -> {
launchSelection(database, nodeId, mSearchInfo)
launchSelection(database, nodeId, mSearchInfo, mOriginApp)
}
SpecialMode.REGISTRATION -> {
// TODO Registration in predefined group
@@ -157,11 +171,6 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
throw InvalidObjectException("Passkey launch mode not supported")
}
}
} catch (e: Exception) {
Log.e(TAG, "Passkey launch error", e)
Toast.makeText(this, e.localizedMessage, Toast.LENGTH_LONG).show()
setResult(RESULT_CANCELED)
finish()
}
}
@@ -196,13 +205,14 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
}
}
private fun launchSelection(
private suspend fun launchSelection(
database: ContextualDatabase?,
nodeId: UUID?,
searchInfo: SearchInfo?
searchInfo: SearchInfo?,
originApp: OriginApp?
) {
Log.d(TAG, "Launch passkey selection")
retrievePasskeyUsageRequestParameters(intent, assets) { usageParameters ->
retrievePasskeyUsageRequestParameters(intent, assets, originApp) { usageParameters ->
// Save the requested parameters
mUsageParameters = usageParameters
// Manage the passkey to use
@@ -214,13 +224,17 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
database = database,
searchInfo = searchInfo,
onItemsFound = { _, _ ->
Log.w(TAG, "Passkey found for auto selection, should not append," +
"use PasskeyProviderService instead")
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")
Log.d(
TAG, "No Passkey found for selection," +
"launch manual selection in opened database"
)
GroupActivity.launchForPasskeySelectionResult(
context = this,
database = openedDatabase,
@@ -259,7 +273,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
}
}
private fun launchRegistration(
private suspend fun launchRegistration(
database: ContextualDatabase?,
nodeId: UUID?,
searchInfo: SearchInfo
@@ -268,15 +282,15 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
retrievePasskeyCreationRequestParameters(
intent = intent,
assetManager = assets,
passkeyCreated = { passkey, publicKeyCredentialParameters ->
passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters ->
// Save the requested parameters
mPasskey = passkey
mCreationParameters = publicKeyCredentialParameters
// Manage the passkey and create a register info
val registerInfo = RegisterInfo(
searchInfo = searchInfo,
username = null,
passkey = passkey
passkey = passkey,
originApp = appInfoToStore
)
// If nodeId already provided
nodeId?.let { nodeId ->
@@ -338,6 +352,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
context: Context,
specialMode: SpecialMode,
searchInfo: SearchInfo? = null,
originApp: OriginApp? = null,
nodeId: UUID? = null
): PendingIntent? {
return PendingIntent.getActivity(
@@ -347,6 +362,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
addSpecialMode(specialMode)
addTypeMode(TypeMode.PASSKEY)
addSearchInfo(searchInfo)
addOriginAppInfo(originApp)
addNodeId(nodeId)
addAuthCode(nodeId)
},

View File

@@ -85,8 +85,9 @@ class PasskeyProviderService : CredentialProviderService() {
private fun buildPasskeySearchInfo(relyingParty: String): SearchInfo {
return SearchInfo().apply {
this.webDomain = relyingParty
this.relyingParty = relyingParty
this.isAPasskeySearch = true
this.query = relyingParty
}
}
@@ -142,7 +143,8 @@ class PasskeyProviderService : CredentialProviderService() {
PasskeyLauncherActivity.getPendingIntent(
context = applicationContext,
specialMode = SpecialMode.SELECTION,
nodeId = passkeyEntry.id
nodeId = passkeyEntry.id,
originApp = passkeyEntry.originApp
)?.let { usagePendingIntent ->
val passkey = passkeyEntry.passkey
passkeyEntries.add(

View File

@@ -19,16 +19,20 @@
*/
package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.content.res.AssetManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.credentials.provider.CallingAppInfo
import kotlinx.coroutines.CoroutineScope
import com.kunzisoft.keepass.model.OriginApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.Locale
@RequiresApi(Build.VERSION_CODES.P)
class OriginManager(
@@ -37,51 +41,53 @@ class OriginManager(
private val assets: AssetManager
) {
fun getOriginAtCreation(
onOriginRetrieved: (origin: String, clientDataHash: ByteArray) -> Unit,
onOriginCreated: (origin: String, signingInfo: SigningInfo) -> Unit
suspend fun getOriginAtCreation(
onOriginRetrieved: (appInfoToStore: OriginApp, clientDataHash: ByteArray) -> Unit,
onOriginCreated: (appInfoToStore: OriginApp, origin: String) -> Unit
) {
getOrigin(
onOriginRetrieved = { callOrigin, clientDataHash ->
onOriginRetrieved(callOrigin, clientDataHash)
onOriginRetrieved(OriginApp(webDomain = callOrigin), clientDataHash)
},
onOriginNotRetrieved = { packageName, signingInfo ->
onOriginNotRetrieved = { storeAppInfo ->
// Create a new Android Origin and prepare the signature app storage
onOriginCreated(buildAndroidOrigin(packageName), signingInfo)
onOriginCreated(
storeAppInfo,
buildAndroidOrigin(storeAppInfo.appId)
)
}
)
}
fun getOriginAtUsage(
storedPackageName: String?,
storedSignature: SigningInfo?,
onOriginRetrieved: (origin: String, clientDataHash: ByteArray) -> Unit,
suspend fun getOriginAtUsage(
appInfoStored: OriginApp?,
onOriginRetrieved: (clientDataHash: ByteArray) -> Unit,
onOriginCreated: (origin: String) -> Unit
) {
getOrigin(
onOriginRetrieved = { callOrigin, clientDataHash ->
onOriginRetrieved(callOrigin, clientDataHash)
onOriginRetrieved = { origin, clientDataHash ->
onOriginRetrieved(clientDataHash)
},
onOriginNotRetrieved = { packageName, signingInfo ->
onOriginNotRetrieved = { appInfoCalled ->
// Verify the app signature to retrieve the origin
// TODO if (packageName == storedPackageName
// && signingInfo == storedSignature) {
onOriginCreated(buildAndroidOrigin(packageName))
//} else {
// throw SecurityException("Android Origin cannot be retrieved, wrong signature")
//}
if (appInfoCalled.appId == appInfoStored?.appId
&& appInfoCalled.appSignature == appInfoStored?.appSignature) {
onOriginCreated(buildAndroidOrigin(appInfoCalled.appId))
} else {
throw SecurityException("Wrong signature for ${appInfoCalled.appId}, ${appInfoCalled.appSignature} retrieved but ${appInfoStored?.appSignature} expected")
}
}
)
}
private fun getOrigin(
onOriginRetrieved: (callOrigin: String, clientDataHash: ByteArray) -> Unit,
onOriginNotRetrieved: (packageName: String, signingInfo: SigningInfo) -> Unit
private suspend fun getOrigin(
onOriginRetrieved: (origin: String, clientDataHash: ByteArray) -> Unit,
onOriginNotRetrieved: (appInfoRetrieved: OriginApp) -> Unit
) {
if (callingAppInfo == null) {
throw SecurityException("Calling app info cannot be retrieved")
}
CoroutineScope(Dispatchers.IO).launch {
withContext(Dispatchers.IO) {
var callOrigin: String?
val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use {
it.readText()
@@ -93,13 +99,94 @@ class OriginManager(
Log.d(TAG, "Origin $callOrigin retrieved from callingAppInfo")
onOriginRetrieved(callOrigin, providedClientDataHash)
} else {
onOriginNotRetrieved(callingAppInfo.packageName, callingAppInfo.signingInfo)
onOriginNotRetrieved(
OriginApp(
appId = callingAppInfo.packageName,
appSignature = getApplicationSignatures(callingAppInfo.signingInfo)
)
)
}
}
}
}
private fun buildAndroidOrigin(packageName: String): String {
// TODO Move in Crypto package and make unit tests
/**
* Converts a Signature object into its SHA-256 fingerprint string.
* The fingerprint is typically represented as uppercase hex characters separated by colons.
*/
private fun signatureToSha256Fingerprint(signature: Signature): String? {
return try {
val certificateFactory = CertificateFactory.getInstance("X.509")
val x509Certificate = certificateFactory.generateCertificate(
signature.toByteArray().inputStream()
) as X509Certificate
val messageDigest = MessageDigest.getInstance("SHA-256")
val digest = messageDigest.digest(x509Certificate.encoded)
// Format as colon-separated HEX uppercase string
digest.joinToString(separator = ":") { byte -> "%02X".format(byte) }
.uppercase(Locale.US)
} catch (e: Exception) {
Log.e("SigningInfoUtil", "Error converting signature to SHA-256 fingerprint", e)
null
}
}
/**
* Retrieves all relevant SHA-256 signature fingerprints for a given package.
*
* @param signingInfo The SigningInfo object to retrieve the strings signatures
* @return A List of SHA-256 fingerprint strings, or null if an error occurs or no signatures are found.
*/
fun getAllSignatures(signingInfo: SigningInfo?): List<String>? {
try {
val signatures = mutableSetOf<String>()
if (signingInfo != null) {
// Includes past and current keys if rotation occurred. This is generally preferred.
signingInfo.signingCertificateHistory?.forEach { signature ->
signatureToSha256Fingerprint(signature)?.let { signatures.add(it) }
}
// If only one signer and history is empty (e.g. new app), this might be needed.
// Or if multiple signers are explicitly used for the APK content.
if (signingInfo.hasMultipleSigners()) {
signingInfo.apkContentsSigners?.forEach { signature ->
signatureToSha256Fingerprint(signature)?.let { signatures.add(it) }
}
} else { // Fallback for single signer if history was somehow null/empty
signingInfo.signingCertificateHistory?.firstOrNull()?.let {
signatureToSha256Fingerprint(it)?.let { fp -> signatures.add(fp) }
}
}
}
return if (signatures.isEmpty()) null else signatures.toList()
} catch (e: Exception) {
Log.e(TAG, "Error getting signatures", e)
return null
}
}
/**
* Combines a list of signature into a single string for database storage.
*
* @return A single string with fingerprints joined by a delimiter, or null if the input list is null or empty.
*/
private fun getApplicationSignatures(signingInfo: SigningInfo?): String? {
val fingerprints = getAllSignatures(signingInfo)
if (fingerprints.isNullOrEmpty()) {
return null
}
return fingerprints.joinToString(SIGNATURE_DELIMITER)
}
/**
* Builds an Android Origin from a package name.
*/
private fun buildAndroidOrigin(packageName: String?): String {
if (packageName.isNullOrEmpty())
throw SecurityException("Package name cannot be empty")
val packageOrigin = "androidapp://${packageName}"
Log.d(TAG, "Origin $packageOrigin retrieved from package name")
return packageOrigin
@@ -107,5 +194,7 @@ class OriginManager(
companion object {
private val TAG = OriginManager::class.simpleName
private const val SIGNATURE_DELIMITER = "##SIG##"
}
}

View File

@@ -51,6 +51,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredential
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.OriginApp
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.utils.StringUtil.toHexString
@@ -73,6 +74,7 @@ object PasskeyHelper {
private const val EXTRA_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO"
private const val EXTRA_ORIGIN_APP_INFO = "com.kunzisoft.keepass.extra.ORIGIN_INFO"
private const val EXTRA_NODE_ID = "com.kunzisoft.keepass.extra.nodeId"
private const val EXTRA_TIMESTAMP = "com.kunzisoft.keepass.extra.timestamp"
private const val EXTRA_AUTHENTICATION_CODE = "com.kunzisoft.keepass.extra.authenticationCode"
@@ -147,6 +149,16 @@ object PasskeyHelper {
return this.getParcelableExtraCompat(EXTRA_SEARCH_INFO)
}
fun Intent.addOriginAppInfo(originApp: OriginApp?) {
originApp?.let {
putExtra(EXTRA_ORIGIN_APP_INFO, originApp)
}
}
fun Intent.retrieveOriginAppInfo(): OriginApp? {
return this.getParcelableExtraCompat(EXTRA_ORIGIN_APP_INFO)
}
fun Intent.addNodeId(nodeId: UUID?) {
nodeId?.let {
putExtra(EXTRA_NODE_ID, ParcelUuid(nodeId))
@@ -246,10 +258,10 @@ object PasskeyHelper {
return request.credentialOptions[0] as GetPublicKeyCredentialOption
}
fun retrievePasskeyCreationRequestParameters(
suspend fun retrievePasskeyCreationRequestParameters(
intent: Intent,
assetManager: AssetManager,
passkeyCreated: (Passkey, PublicKeyCredentialCreationParameters) -> Unit
passkeyCreated: (Passkey, OriginApp?, PublicKeyCredentialCreationParameters) -> Unit
) {
val createCredentialRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
if (createCredentialRequest == null)
@@ -285,9 +297,10 @@ object PasskeyHelper {
callingAppInfo = callingAppInfo,
assets = assetManager
).getOriginAtCreation(
onOriginRetrieved = { origin, clientDataHash ->
onOriginRetrieved = { appInfoToStore, clientDataHash ->
passkeyCreated.invoke(
passkey,
appInfoToStore,
PublicKeyCredentialCreationParameters(
publicKeyCredentialCreationOptions = creationOptions,
credentialId = credentialId,
@@ -296,10 +309,10 @@ object PasskeyHelper {
)
)
},
onOriginCreated = { origin, signingInfo ->
// TODO store signature
onOriginCreated = { appInfoToStore, origin ->
passkeyCreated.invoke(
passkey,
appInfoToStore,
PublicKeyCredentialCreationParameters(
publicKeyCredentialCreationOptions = creationOptions,
credentialId = credentialId,
@@ -346,9 +359,10 @@ object PasskeyHelper {
return CreatePublicKeyCredentialResponse(responseJson)
}
fun retrievePasskeyUsageRequestParameters(
suspend fun retrievePasskeyUsageRequestParameters(
intent: Intent,
assetManager: AssetManager,
originApp: OriginApp?,
result: (PublicKeyCredentialUsageParameters) -> Unit
) {
val getCredentialRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
@@ -359,16 +373,14 @@ object PasskeyHelper {
val clientDataHash = credentialOption.clientDataHash
val requestOptions = PublicKeyCredentialRequestOptions(credentialOption.requestJson)
val relyingParty = requestOptions.rpId
OriginManager(
providedClientDataHash = clientDataHash,
callingAppInfo = callingAppInfo,
assets = assetManager
).getOriginAtUsage(
storedPackageName = null, // TODO Retrieved package name and signature
storedSignature = null,
onOriginRetrieved = { origin, clientDataHash ->
appInfoStored = originApp,
onOriginRetrieved = { clientDataHash ->
result.invoke(
PublicKeyCredentialUsageParameters(
publicKeyCredentialRequestOptions = requestOptions,

View File

@@ -20,6 +20,7 @@ import com.kunzisoft.keepass.database.helper.getLocalizedName
import com.kunzisoft.keepass.database.helper.isStandardPasswordName
import com.kunzisoft.keepass.model.DataDate
import com.kunzisoft.keepass.model.DataTime
import com.kunzisoft.keepass.model.OriginAppEntryField
import com.kunzisoft.keepass.model.PasskeyEntryFields
import com.kunzisoft.keepass.otp.OtpEntryFields
@@ -265,6 +266,9 @@ class TemplateEditView @JvmOverloads constructor(context: Context,
mEntryInfo?.passkey = PasskeyEntryFields.parseFields { key ->
getCustomField(key).protectedValue.toString()
}
mEntryInfo?.originApp = OriginAppEntryField.parseFields { key ->
getCustomField(key).protectedValue.toString()
}
}
override fun onRestoreEntryInstanceState(state: SavedState) {

View File

@@ -36,7 +36,7 @@ import androidx.core.text.util.LinkifyCompat
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.model.EntryInfo.Companion.APPLICATION_ID_FIELD_NAME
import com.kunzisoft.keepass.model.OriginAppEntryField.APPLICATION_ID_FIELD_NAME
import com.kunzisoft.keepass.utils.UriUtil.openExternalApp

View File

@@ -174,7 +174,8 @@ class EntryEditViewModel: NodeEditViewModel() {
// Load entry info
entry.getEntryInfo(database, true).let { tempEntryInfo ->
// Retrieve data from registration
(registerInfo?.searchInfo ?: searchInfo)?.let { tempSearchInfo ->
// TODO only save registration
searchInfo?.let { tempSearchInfo ->
tempEntryInfo.saveSearchInfo(database, tempSearchInfo)
}
registerInfo?.let { regInfo ->

View File

@@ -34,6 +34,8 @@ import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.element.node.Type
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.OriginApp
import com.kunzisoft.keepass.model.OriginAppEntryField
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.PasskeyEntryFields
import com.kunzisoft.keepass.otp.OtpElement
@@ -365,6 +367,15 @@ class Entry : Node, EntryVersionedInterface<Group> {
return null
}
fun getOriginApp(): OriginApp? {
entryKDBX?.let {
return OriginAppEntryField.parseFields { key ->
it.getFieldValue(key)?.toString()
}
}
return null
}
fun startToManageFieldReferences(database: DatabaseKDBX) {
entryKDBX?.startToManageFieldReferences(database)
}
@@ -483,6 +494,7 @@ class Entry : Node, EntryVersionedInterface<Group> {
entryInfo.otpModel = getOtpElement()?.otpModel
// Add Passkey
entryInfo.passkey = getPasskey()
entryInfo.originApp = getOriginApp()
if (!raw) {
// Replace parameter fields by generated OTP fields
entryInfo.customFields = OtpEntryFields.generateAutoFields(entryInfo.customFields)

View File

@@ -0,0 +1,86 @@
/*
* 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.model
import com.kunzisoft.keepass.database.element.DateInstant
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.database.element.template.TemplateField.LABEL_CVV
import com.kunzisoft.keepass.database.element.template.TemplateField.LABEL_HOLDER
import com.kunzisoft.keepass.database.element.template.TemplateField.LABEL_NUMBER
import org.joda.time.DateTime
object CreditCardEntryFields {
const val CREDIT_CARD_TAG = "Credit Card"
/**
* Parse fields of an entry to retrieve a Passkey
*/
fun parseFields(getField: (id: String) -> String?): CreditCard? {
val cardHolderField = getField(LABEL_HOLDER)
val cardNumberField = getField(LABEL_NUMBER)
val cardExpiration = DateTime() // TODO Expiration
val cardCVVField = getField(LABEL_CVV)
if (cardHolderField == null
|| cardNumberField == null)
return null
return CreditCard(
cardholder = cardHolderField,
number = cardNumberField,
expiration = cardExpiration,
cvv = cardCVVField
)
}
fun EntryInfo.setCreditCard(creditCard: CreditCard?) {
if (creditCard != null) {
tags.put(CREDIT_CARD_TAG)
creditCard.cardholder?.let {
addOrReplaceField(
Field(
LABEL_HOLDER,
ProtectedString(enableProtection = false, it)
)
)
}
creditCard.number?.let {
addOrReplaceField(
Field(
LABEL_NUMBER,
ProtectedString(enableProtection = false, it)
)
)
}
creditCard.expiration?.let {
expires = true
expiryTime = DateInstant(creditCard.expiration.toInstant())
}
creditCard.cvv?.let {
addOrReplaceField(
Field(
LABEL_CVV,
ProtectedString(enableProtection = true, it)
)
)
}
}
}
}

View File

@@ -24,18 +24,19 @@ import android.os.ParcelUuid
import android.os.Parcelable
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.CreditCardEntryFields.setCreditCard
import com.kunzisoft.keepass.model.OriginAppEntryField.setApplicationId
import com.kunzisoft.keepass.model.OriginAppEntryField.setOriginApp
import com.kunzisoft.keepass.model.OriginAppEntryField.setWebDomain
import com.kunzisoft.keepass.model.PasskeyEntryFields.isPasskeyExclusion
import com.kunzisoft.keepass.model.PasskeyEntryFields.setPasskey
import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD
import com.kunzisoft.keepass.otp.OtpEntryFields.isOtpExclusion
import com.kunzisoft.keepass.otp.OtpEntryFields.setOtp
import com.kunzisoft.keepass.utils.readBooleanCompat
import com.kunzisoft.keepass.utils.readListCompat
import com.kunzisoft.keepass.utils.readParcelableCompat
@@ -58,6 +59,7 @@ class EntryInfo : NodeInfo {
var autoType: AutoType = AutoType()
var otpModel: OtpModel? = null
var passkey: Passkey? = null
var originApp: OriginApp? = null
var isTemplate: Boolean = false
constructor() : super()
@@ -77,6 +79,8 @@ class EntryInfo : NodeInfo {
parcel.readListCompat(attachments)
autoType = parcel.readParcelableCompat() ?: autoType
otpModel = parcel.readParcelableCompat() ?: otpModel
passkey = parcel.readParcelableCompat() ?: passkey
originApp = parcel.readParcelableCompat() ?: originApp
isTemplate = parcel.readBooleanCompat()
}
@@ -98,6 +102,8 @@ class EntryInfo : NodeInfo {
parcel.writeList(attachments)
parcel.writeParcelable(autoType, flags)
parcel.writeParcelable(otpModel, flags)
parcel.writeParcelable(passkey, flags)
parcel.writeParcelable(originApp, flags)
parcel.writeBooleanCompat(isTemplate)
}
@@ -121,6 +127,9 @@ class EntryInfo : NodeInfo {
return customFields.lastOrNull { it.name == label }?.protectedValue?.toString() ?: ""
}
/**
* Add a field to the custom fields list, replace if name already exists
*/
fun addOrReplaceField(field: Field) {
customFields.lastOrNull { it.name == field.name }?.let {
it.apply {
@@ -129,136 +138,90 @@ class EntryInfo : NodeInfo {
} ?: customFields.add(field)
}
// Return true if modified
private fun addUniqueField(field: Field, number: Int = 0) {
var sameName = false
var sameValue = false
val suffix = if (number > 0) "_$number" else ""
customFields.forEach { currentField ->
// Not write the same data again
if (currentField.protectedValue.stringValue == field.protectedValue.stringValue) {
sameValue = true
return
}
// Same name but new value, create a new suffix
if (currentField.name == field.name + suffix) {
sameName = true
addUniqueField(field, number + 1)
return
}
}
if (!sameName && !sameValue)
(customFields as ArrayList<Field>).add(Field(field.name + suffix, field.protectedValue))
/**
* Create a field name suffix depending on the field position
*/
private fun suffixFieldNamePosition(position: Int): String {
return if (position > 0) "_$position" else ""
}
private fun containsDomainOrApplicationId(search: String): Boolean {
if (url.contains(search))
return true
return customFields.find {
it.protectedValue.stringValue.contains(search)
} != null
/**
* Add a field to the custom fields list with a suffix position,
* replace if name already exists
*/
fun addOrReplaceFieldWithSuffix(field: Field, position: Int) {
addOrReplaceField(Field(
field.name + suffixFieldNamePosition(position),
field.protectedValue)
)
}
/**
* Add an unique field to the custom fields list with a suffix
* if name already exists and value not the same
* @param field the field to add
* @param position the number to add to the suffix
* @return the increment number and the custom field created
*/
fun addUniqueField(field: Field, position: Int = 0): Pair<Int, Field> {
val suffix = suffixFieldNamePosition(position)
if (customFields.any { currentField -> currentField.name == field.name + suffix }) {
val fieldFound = customFields.find {
it.name == field.name + suffix
&& it.protectedValue.stringValue == field.protectedValue.stringValue
}
return if (fieldFound != null) {
Pair(position, fieldFound)
} else {
addUniqueField(field, position + 1)
}
} else {
val field = Field(field.name + suffix, field.protectedValue)
customFields.add(field)
return Pair(position, field)
}
}
/**
* Add searchInfo to current EntryInfo, return true if new data, false if no modification
*/
fun saveSearchInfo(database: Database?, searchInfo: SearchInfo): Boolean {
var modification = false
fun saveSearchInfo(database: Database?, searchInfo: SearchInfo) {
searchInfo.otpString?.let { otpString ->
// Replace the OTP field
OtpEntryFields.parseOTPUri(otpString)?.let { otpElement ->
if (title.isEmpty())
title = otpElement.issuer
if (username.isEmpty())
username = otpElement.name
// Add OTP field
val mutableCustomFields = customFields as ArrayList<Field>
val otpField = OtpEntryFields.buildOtpField(otpElement, null, null)
if (mutableCustomFields.contains(otpField)) {
mutableCustomFields.remove(otpField)
}
mutableCustomFields.add(otpField)
modification = true
}
setOtp(otpString)
} ?: searchInfo.webDomain?.let { webDomain ->
// If unable to save web domain in custom field or URL not populated, save in URL
val scheme = searchInfo.webScheme
val webScheme = if (scheme.isNullOrEmpty()) "https" else scheme
val webDomainToStore = "$webScheme://$webDomain"
if (!containsDomainOrApplicationId(webDomain)) {
if (database?.allowEntryCustomFields() != true || url.isEmpty()) {
url = webDomainToStore
} else {
// Save web domain in custom field
addUniqueField(
Field(
WEB_DOMAIN_FIELD_NAME,
ProtectedString(false, webDomainToStore)
),
1 // Start to one because URL is a standard field name
)
}
modification = true
}
setWebDomain(
webDomain,
searchInfo.webScheme,
database?.allowEntryCustomFields() == true
)
} ?: searchInfo.applicationId?.let { applicationId ->
// Save application id in custom field
if (database?.allowEntryCustomFields() == true) {
if (!containsDomainOrApplicationId(applicationId)) {
addUniqueField(
Field(
APPLICATION_ID_FIELD_NAME,
ProtectedString(false, applicationId)
)
)
modification = true
}
}
setApplicationId(applicationId)
}
if (title.isEmpty()) {
title = searchInfoToTitle(searchInfo)
title = searchInfo.toTitle()
}
return modification
}
/**
* Capitalize and remove suffix of web domain to create a title
*/
private fun searchInfoToTitle(searchInfo: SearchInfo): String {
val webDomain = searchInfo.webDomain
fun SearchInfo.toTitle(): String {
val webDomain = this.webDomain
return webDomain?.substring(0, webDomain.lastIndexOf('.'))?.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
} ?: searchInfo.toString()
} ?: this.toString()
}
fun saveRegisterInfo(database: Database?, registerInfo: RegisterInfo) {
saveSearchInfo(database, registerInfo.searchInfo)
registerInfo.username?.let {
username = it
}
registerInfo.password?.let {
password = it
}
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)))
}
creditCard?.expiration?.let {
expires = true
expiryTime = DateInstant(creditCard.expiration.toInstant())
}
creditCard?.number?.let {
addUniqueField(Field(TemplateField.LABEL_NUMBER, ProtectedString(false, it)))
}
creditCard?.cvv?.let {
addUniqueField(Field(TemplateField.LABEL_CVV, ProtectedString(true, it)))
}
registerInfo.passkey?.let {
setPasskey(it)
}
}
registerInfo.username?.let { username = it }
registerInfo.password?.let { password = it }
setCreditCard(registerInfo.creditCard)
setPasskey(registerInfo.passkey)
setOriginApp(
registerInfo.originApp,
database?.allowEntryCustomFields() == true
)
}
fun getVisualTitle(): String {
@@ -286,6 +249,8 @@ class EntryInfo : NodeInfo {
if (attachments != other.attachments) return false
if (autoType != other.autoType) return false
if (otpModel != other.otpModel) return false
if (passkey != other.passkey) return false
if (originApp != other.originApp) return false
if (isTemplate != other.isTemplate) return false
return true
@@ -305,6 +270,8 @@ class EntryInfo : NodeInfo {
result = 31 * result + attachments.hashCode()
result = 31 * result + autoType.hashCode()
result = 31 * result + (otpModel?.hashCode() ?: 0)
result = 31 * result + (passkey?.hashCode() ?: 0)
result = 31 * result + (originApp?.hashCode() ?: 0)
result = 31 * result + isTemplate.hashCode()
return result
}
@@ -312,9 +279,6 @@ class EntryInfo : NodeInfo {
companion object {
const val WEB_DOMAIN_FIELD_NAME = "URL"
const val APPLICATION_ID_FIELD_NAME = "AndroidApp"
@JvmField
val CREATOR: Parcelable.Creator<EntryInfo> = object : Parcelable.Creator<EntryInfo> {
override fun createFromParcel(parcel: Parcel): EntryInfo {

View File

@@ -0,0 +1,30 @@
/*
* 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.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class OriginApp(
val appId: String? = null,
val appSignature: String? = null,
val webDomain: String? = null
) : Parcelable

View File

@@ -0,0 +1,111 @@
/*
* 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.model
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
object OriginAppEntryField {
const val WEB_DOMAIN_FIELD_NAME = "URL"
const val APPLICATION_ID_FIELD_NAME = "AndroidApp"
const val APPLICATION_SIGNATURE_FIELD_NAME = "AndroidApp Signature"
/**
* Parse fields of an entry to retrieve a an OriginApp
*/
fun parseFields(getField: (id: String) -> String?): OriginApp {
val appIdField = getField(APPLICATION_ID_FIELD_NAME)
val appSignatureField = getField(APPLICATION_SIGNATURE_FIELD_NAME)
val webDomainField = getField(WEB_DOMAIN_FIELD_NAME)
return OriginApp(
appId = appIdField,
appSignature = appSignatureField,
webDomain = webDomainField
)
}
/**
* Useful to detect if an other KeePass compatibility app already add a web domain or an app id
*/
private fun EntryInfo.containsDomainOrApplicationId(search: String): Boolean {
if (url.contains(search))
return true
return customFields.find {
it.protectedValue.stringValue.contains(search)
} != null
}
fun EntryInfo.setWebDomain(webDomain: String?, scheme: String?, customFieldsAllowed: Boolean) {
// If unable to save web domain in custom field or URL not populated, save in URL
webDomain?.let {
val webScheme = if (scheme.isNullOrEmpty()) "https" else scheme
val webDomainToStore = "$webScheme://$webDomain"
if (!containsDomainOrApplicationId(webDomain)) {
if (!customFieldsAllowed || url.isEmpty()) {
url = webDomainToStore
} else {
// Save web domain in custom field
addUniqueField(
Field(
WEB_DOMAIN_FIELD_NAME,
ProtectedString(false, webDomainToStore)
),
1 // Start to one because URL is a standard field name
)
}
}
}
}
/**
* Save application id in custom field and the application signature if provided
*/
fun EntryInfo.setApplicationId(applicationId: String?, signature: String? = null) {
// Save application id in custom field
applicationId?.let {
// Check compatibility with other KeePass client unless a signature need to be saved
if (!containsDomainOrApplicationId(applicationId) || signature != null) {
val position = addUniqueField(
Field(
APPLICATION_ID_FIELD_NAME,
ProtectedString(false, applicationId)
)
).first
signature?.let {
addOrReplaceFieldWithSuffix(
Field(
APPLICATION_SIGNATURE_FIELD_NAME,
ProtectedString(true, signature)
),
position
)
}
}
}
}
fun EntryInfo.setOriginApp(originApp: OriginApp?, customFieldsAllowed: Boolean) {
if (originApp != null) {
setApplicationId(originApp.appId, originApp.appSignature)
setWebDomain(originApp.webDomain, null, customFieldsAllowed)
}
}
}

View File

@@ -1,3 +1,22 @@
/*
* 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.model
import android.os.Parcelable

View File

@@ -40,38 +40,40 @@ object PasskeyEntryFields {
)
}
fun EntryInfo.setPasskey(passkey: Passkey) {
tags.put(PASSKEY_TAG)
addOrReplaceField(
Field(
FIELD_USERNAME,
ProtectedString(enableProtection = false, passkey.username)
fun EntryInfo.setPasskey(passkey: Passkey?) {
if (passkey != null) {
tags.put(PASSKEY_TAG)
addOrReplaceField(
Field(
FIELD_USERNAME,
ProtectedString(enableProtection = false, passkey.username)
)
)
)
addOrReplaceField(
Field(
FIELD_PRIVATE_KEY,
ProtectedString(enableProtection = true, passkey.privateKeyPem)
addOrReplaceField(
Field(
FIELD_PRIVATE_KEY,
ProtectedString(enableProtection = true, passkey.privateKeyPem)
)
)
)
addOrReplaceField(
Field(
FIELD_CREDENTIAL_ID,
ProtectedString(enableProtection = true, passkey.credentialId)
addOrReplaceField(
Field(
FIELD_CREDENTIAL_ID,
ProtectedString(enableProtection = true, passkey.credentialId)
)
)
)
addOrReplaceField(
Field(
FIELD_USER_HANDLE,
ProtectedString(enableProtection = true, passkey.userHandle)
addOrReplaceField(
Field(
FIELD_USER_HANDLE,
ProtectedString(enableProtection = true, passkey.userHandle)
)
)
)
addOrReplaceField(
Field(
FIELD_RELYING_PARTY,
ProtectedString(enableProtection = false, passkey.relyingParty)
addOrReplaceField(
Field(
FIELD_RELYING_PARTY,
ProtectedString(enableProtection = false, passkey.relyingParty)
)
)
)
}
}
/**

View File

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

View File

@@ -35,6 +35,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
get() {
return if (webDomain == null) null else field
}
var relyingParty: String? = null
var otpString: String? = null
constructor()
@@ -45,19 +46,22 @@ class SearchInfo : ObjectNameResource, Parcelable {
applicationId = toCopy?.applicationId
webDomain = toCopy?.webDomain
webScheme = toCopy?.webScheme
relyingParty = toCopy?.relyingParty
otpString = toCopy?.otpString
}
private constructor(parcel: Parcel) {
manualSelection = parcel.readBooleanCompat()
val readTag = parcel.readString()
tag = if (readTag.isNullOrEmpty()) null else readTag
tag = if (readTag.isNullOrEmpty()) null else readTag
val readAppId = parcel.readString()
applicationId = if (readAppId.isNullOrEmpty()) null else readAppId
applicationId = if (readAppId.isNullOrEmpty()) null else readAppId
val readDomain = parcel.readString()
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
}
@@ -72,6 +76,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
parcel.writeString(applicationId ?: "")
parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "")
parcel.writeString(relyingParty ?: "")
parcel.writeString(otpString ?: "")
}
@@ -89,6 +94,7 @@ class SearchInfo : ObjectNameResource, Parcelable {
&& applicationId == null
&& webDomain == null
&& webScheme == null
&& relyingParty == null
&& otpString == null
}
@@ -98,9 +104,11 @@ class SearchInfo : ObjectNameResource, Parcelable {
var isAPasskeySearch: Boolean = false
var query: String? = null
fun buildSearchParameters(): SearchParameters {
return SearchParameters().apply {
searchQuery = this@SearchInfo.toString()
searchQuery = query ?: this@SearchInfo.toString()
allowEmptyQuery = false
searchInTitles = !isAPasskeySearch
searchInUsernames = false
@@ -129,6 +137,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
@@ -140,12 +149,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 ?: tag ?: ""
return otpString ?: webDomain ?: applicationId ?: relyingParty ?: tag ?: ""
}
companion object {

View File

@@ -24,6 +24,7 @@ import android.net.Uri
import android.util.Log
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.otp.TokenCalculator.HOTP_INITIAL_COUNTER
import com.kunzisoft.keepass.otp.TokenCalculator.HashAlgorithm
import com.kunzisoft.keepass.otp.TokenCalculator.OTP_DEFAULT_DIGITS
@@ -434,6 +435,25 @@ object OtpEntryFields {
buildOtpUri(otpElement, title, username).toString()))
}
fun EntryInfo.setOtp(otpString: String): Boolean {
// Replace the OTP field
parseOTPUri(otpString)?.let { otpElement ->
if (title.isEmpty())
title = otpElement.issuer
if (username.isEmpty())
username = otpElement.name
// Add OTP field
val mutableCustomFields = customFields as ArrayList<Field>
val otpField = OtpEntryFields.buildOtpField(otpElement, null, null)
if (mutableCustomFields.contains(otpField)) {
mutableCustomFields.remove(otpField)
}
mutableCustomFields.add(otpField)
return true
}
return false
}
/**
* Build new generated fields in a new list from [fieldsToParse] in parameter,
* Remove parameters fields use to generate auto fields