mirror of
https://github.com/Kunzisoft/KeePassDX.git
synced 2025-12-04 15:49:33 +01:00
feat: Add app Signature
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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##"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user