fix: First validation pass

This commit is contained in:
J-Jamet
2025-09-02 11:49:40 +02:00
parent 5e4ee167fc
commit d36f675da7
101 changed files with 6028 additions and 1133 deletions

View File

@@ -1,5 +1,6 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
android {
namespace 'com.kunzisoft.keepass.database'
@@ -41,7 +42,7 @@ dependencies {
implementation 'commons-io:commons-io:2.8.0'
implementation 'commons-codec:commons-codec:1.15'
implementation project(path: ':crypto')
api project(path: ':crypto')
// Tests
androidTestImplementation "androidx.test:runner:$android_test_version"

View File

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

View File

@@ -33,12 +33,16 @@ import com.kunzisoft.keepass.database.element.node.Node
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.AppOrigin
import com.kunzisoft.keepass.model.AppOriginEntryField
import com.kunzisoft.keepass.model.EntryInfo
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.PasskeyEntryFields
import com.kunzisoft.keepass.otp.OtpElement
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.readParcelableCompat
import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorInt
import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorString
import com.kunzisoft.keepass.utils.readParcelableCompat
import java.util.UUID
class Entry : Node, EntryVersionedInterface<Group> {
@@ -354,6 +358,24 @@ class Entry : Node, EntryVersionedInterface<Group> {
return null
}
fun getPasskey(): Passkey? {
entryKDBX?.let {
return PasskeyEntryFields.parseFields { key ->
it.getFieldValue(key)?.toString()
}
}
return null
}
fun getAppOrigin(): AppOrigin? {
entryKDBX?.let {
return AppOriginEntryField.parseFields { key ->
it.getFieldValue(key)?.toString()
}
}
return null
}
fun startToManageFieldReferences(database: DatabaseKDBX) {
entryKDBX?.startToManageFieldReferences(database)
}
@@ -470,9 +492,13 @@ class Entry : Node, EntryVersionedInterface<Group> {
entryInfo.customFields = getExtraFields().toMutableList()
// Add otpElement to generate token
entryInfo.otpModel = getOtpElement()?.otpModel
// Add Passkey
entryInfo.passkey = getPasskey()
entryInfo.appOrigin = getAppOrigin()
if (!raw) {
// Replace parameter fields by generated OTP fields
entryInfo.customFields = OtpEntryFields.generateAutoFields(entryInfo.customFields)
entryInfo.customFields = PasskeyEntryFields.generateAutoFields(entryInfo.customFields)
}
database?.attachmentPool?.let { binaryPool ->
entryInfo.attachments = getAttachments(binaryPool).toMutableList()

View File

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

View File

@@ -22,7 +22,8 @@ package com.kunzisoft.keepass.database.element.entry
import android.util.Log
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.UUIDUtils.asUUID
import java.util.concurrent.ConcurrentHashMap
class FieldReferencesEngine(private val mDatabase: DatabaseKDBX) {
@@ -79,7 +80,7 @@ class FieldReferencesEngine(private val mDatabase: DatabaseKDBX) {
'A' -> entryFound?.decodeUrlKey(newRecursionLevel)
'P' -> entryFound?.decodePasswordKey(newRecursionLevel)
'N' -> entryFound?.decodeNotesKey(newRecursionLevel)
'I' -> UuidUtil.toHexString(entryFound?.nodeId?.id)
'I' -> entryFound?.nodeId?.id?.asHexString()
else -> null
}
refsCache[fullReference] = data
@@ -127,7 +128,7 @@ class FieldReferencesEngine(private val mDatabase: DatabaseKDBX) {
'P' -> mDatabase.getEntryByPassword(searchQuery, recursionLevel)
'N' -> mDatabase.getEntryByNotes(searchQuery, recursionLevel)
'I' -> {
UuidUtil.fromHexString(searchQuery)?.let { uuid ->
searchQuery.asUUID()?.let { uuid ->
mDatabase.getEntryById(NodeIdUUID(uuid))
}
}

View File

@@ -22,9 +22,9 @@ package com.kunzisoft.keepass.database.element.node
import android.os.Parcel
import android.os.ParcelUuid
import android.os.Parcelable
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.readParcelableCompat
import com.kunzisoft.keepass.utils.UuidUtil
import java.util.*
import java.util.UUID
class NodeIdUUID : NodeId<UUID> {
@@ -62,7 +62,7 @@ class NodeIdUUID : NodeId<UUID> {
}
override fun toString(): String {
return UuidUtil.toHexString(id) ?: id.toString()
return id.asHexString() ?: id.toString()
}
override fun toVisualString(): String {

View File

@@ -24,7 +24,8 @@ import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.database.DatabaseKDBX
import com.kunzisoft.keepass.database.element.entry.EntryKDBX
import com.kunzisoft.keepass.database.element.security.ProtectedString
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.UUIDUtils.asUUID
class TemplateEngineCompatible(database: DatabaseKDBX): TemplateEngine(database) {
@@ -33,7 +34,7 @@ class TemplateEngineCompatible(database: DatabaseKDBX): TemplateEngine(database)
}
override fun getTemplate(entryKDBX: EntryKDBX): Template? {
UuidUtil.fromHexString(entryKDBX.getCustomFieldValue(TEMPLATE_ENTRY_UUID))?.let { templateUUID ->
entryKDBX.getCustomFieldValue(TEMPLATE_ENTRY_UUID).asUUID()?.let { templateUUID ->
return getTemplateByCache(templateUUID)
}
return null
@@ -48,7 +49,7 @@ class TemplateEngineCompatible(database: DatabaseKDBX): TemplateEngine(database)
}
private fun getTemplateUUIDField(template: Template): Field? {
UuidUtil.toHexString(template.uuid)?.let { uuidString ->
template.uuid.asHexString()?.let { uuidString ->
return Field(TEMPLATE_ENTRY_UUID,
ProtectedString(false, uuidString))
}

View File

@@ -24,8 +24,11 @@ import com.kunzisoft.keepass.database.element.Entry
import com.kunzisoft.keepass.database.element.Group
import com.kunzisoft.keepass.database.element.node.NodeHandler
import com.kunzisoft.keepass.database.element.node.NodeId
import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_RELYING_PARTY
import com.kunzisoft.keepass.model.PasskeyEntryFields.isPasskeyExclusion
import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_FIELD
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.otp.OtpEntryFields.isOtpExclusion
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.inTheSameDomainAs
class SearchHelper {
@@ -163,18 +166,31 @@ class SearchHelper {
return true
}
if (searchParameters.searchInUUIDs) {
val hexString = UuidUtil.toHexString(entry.nodeId.id) ?: ""
val hexString = entry.nodeId.id.asHexString() ?: ""
if (checkSearchQuery(hexString, searchParameters))
return true
}
if (searchParameters.searchInOTP) {
if(entry.getExtraFields().any { field ->
field.name == OTP_FIELD
&& checkSearchQuery(field.protectedValue.stringValue, searchParameters)
})
return true
}
if (searchParameters.searchInRelyingParty) {
if(entry.getExtraFields().any { field ->
field.name == FIELD_RELYING_PARTY
&& checkSearchQuery(field.protectedValue.stringValue, searchParameters)
})
return true
}
if (searchParameters.searchInOther) {
entry.getExtraFields().forEach { field ->
if (field.name != OTP_FIELD
|| (field.name == OTP_FIELD && searchParameters.searchInOTP)) {
if (checkSearchQuery(field.protectedValue.toString(), searchParameters))
return true
}
}
if(entry.getExtraFields().any { field ->
field.isOtpExclusion()
&& field.isPasskeyExclusion()
&& checkSearchQuery(field.protectedValue.toString(), searchParameters)
})
return true
}
if (searchParameters.searchInTags) {
if (checkSearchQuery(entry.tags.toString(), searchParameters))

View File

@@ -38,6 +38,7 @@ class SearchParameters() : Parcelable{
var searchInExpired = false
var searchInNotes = true
var searchInOTP = false
var searchInRelyingParty = false
var searchInOther = true
var searchInUUIDs = false
var searchInTags = false
@@ -60,6 +61,7 @@ class SearchParameters() : Parcelable{
searchInExpired = parcel.readByte() != 0.toByte()
searchInNotes = parcel.readByte() != 0.toByte()
searchInOTP = parcel.readByte() != 0.toByte()
searchInRelyingParty = parcel.readByte() != 0.toByte()
searchInOther = parcel.readByte() != 0.toByte()
searchInUUIDs = parcel.readByte() != 0.toByte()
searchInTags = parcel.readByte() != 0.toByte()
@@ -81,6 +83,7 @@ class SearchParameters() : Parcelable{
parcel.writeByte(if (searchInExpired) 1 else 0)
parcel.writeByte(if (searchInNotes) 1 else 0)
parcel.writeByte(if (searchInOTP) 1 else 0)
parcel.writeByte(if (searchInRelyingParty) 1 else 0)
parcel.writeByte(if (searchInOther) 1 else 0)
parcel.writeByte(if (searchInUUIDs) 1 else 0)
parcel.writeByte(if (searchInTags) 1 else 0)

View File

@@ -0,0 +1,143 @@
/*
* 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 android.util.Log
import com.kunzisoft.encrypt.HashManager.fingerprintToUrlSafeBase64
import com.kunzisoft.keepass.model.WebOrigin.Companion.RELYING_PARTY_DEFAULT_PROTOCOL
import kotlinx.parcelize.Parcelize
@Parcelize
data class AppOrigin(
val verified: Boolean,
val androidOrigins: MutableList<AndroidOrigin> = mutableListOf(),
val webOrigins: MutableList<WebOrigin> = mutableListOf(),
) : Parcelable {
fun addAndroidOrigin(androidOrigin: AndroidOrigin) {
androidOrigins.add(androidOrigin)
}
fun addWebOrigin(webOrigin: WebOrigin) {
this.webOrigins.add(webOrigin)
}
/**
* Verify the app origin by comparing it to the list of android origins,
* return the first verified origin or throw an exception if none is found
*/
fun checkAppOrigin(compare: AppOrigin): String {
return androidOrigins.firstOrNull { androidOrigin ->
compare.androidOrigins.any {
it.packageName == androidOrigin.packageName
&& it.fingerprint == androidOrigin.fingerprint
}
}?.let {
AndroidOrigin(
packageName = it.packageName,
fingerprint = it.fingerprint
).toAndroidOrigin()
} ?: throw SecurityException("Wrong signature for ${toName()}")
}
fun clear() {
androidOrigins.clear()
webOrigins.clear()
}
fun isEmpty(): Boolean {
return androidOrigins.isEmpty() && webOrigins.isEmpty()
}
fun toName(): String? {
return if (androidOrigins.isNotEmpty()) {
androidOrigins.first().packageName
} else if (webOrigins.isNotEmpty()){
webOrigins.first().origin
} else null
}
companion object {
private val TAG = AppOrigin::class.java.simpleName
fun fromOrigin(origin: String, androidOrigin: AndroidOrigin, verified: Boolean): AppOrigin {
val appOrigin = AppOrigin(verified)
if (origin.startsWith(RELYING_PARTY_DEFAULT_PROTOCOL)) {
appOrigin.apply {
addWebOrigin(WebOrigin(origin))
}
} else {
Log.w(TAG, "Unknown verified origin $origin")
appOrigin.apply {
addAndroidOrigin(androidOrigin)
}
}
return appOrigin
}
}
}
@Parcelize
data class AndroidOrigin(
val packageName: String,
val fingerprint: String?
) : Parcelable {
/**
* Creates an Android App Origin string of the form "android:apk-key-hash:<base64_urlsafe_hash>"
* from a colon-separated hex fingerprint string.
*
* The input fingerprint is assumed to be the SHA-256 hash of the app's signing certificate.
*
* @param fingerprint The colon-separated hex fingerprint string (e.g., "91:F7:CB:...").
* @return The Android App Origin string.
* @throws IllegalArgumentException if the hex string (after removing colons) has an odd length
* or contains non-hex characters.
*/
fun toAndroidOrigin(): String {
if (fingerprint == null) {
throw IllegalArgumentException("Fingerprint $fingerprint cannot be null")
}
return "android:apk-key-hash:${fingerprintToUrlSafeBase64(fingerprint)}"
}
}
@Parcelize
data class WebOrigin(
val origin: String
) : Parcelable {
fun toWebOrigin(): String {
return origin
}
fun defaultAssetLinks(): String {
return "${origin}/.well-known/assetlinks.json"
}
companion object {
const val RELYING_PARTY_DEFAULT_PROTOCOL = "https"
fun fromRelyingParty(relyingParty: String): WebOrigin = WebOrigin(
origin ="$RELYING_PARTY_DEFAULT_PROTOCOL://$relyingParty"
)
}
}

View File

@@ -0,0 +1,147 @@
/*
* 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
import com.kunzisoft.keepass.model.EntryInfo.Companion.suffixFieldNamePosition
object AppOriginEntryField {
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 AppOrigin
*/
fun parseFields(getField: (id: String) -> String?): AppOrigin {
val appOrigin = AppOrigin(verified = true)
// Get Application identifiers
generateSequence(0) { it + 1 }
.map { position ->
val appId = getField(APPLICATION_ID_FIELD_NAME + suffixFieldNamePosition(position))
val appSignature = getField(APPLICATION_SIGNATURE_FIELD_NAME + suffixFieldNamePosition(position))
// Pair them up, if appId is null, we stop
if (appId != null) {
appId to (appSignature ?: "")
} else {
// Stop
null
}
}.takeWhile { it != null }
.forEach { pair ->
appOrigin.addAndroidOrigin(
AndroidOrigin(pair!!.first, pair.second)
)
}
// Get Domains
var domainFieldPosition = 0
while (true) {
val domainKey = WEB_DOMAIN_FIELD_NAME + suffixFieldNamePosition(domainFieldPosition)
val domainValue = getField(domainKey)
if (domainValue != null) {
appOrigin.addWebOrigin(WebOrigin(origin = domainValue))
domainFieldPosition++
} else {
break // No more domain found
}
}
return appOrigin
}
/**
* 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 = if (webDomain.contains("://")) {
webDomain
} else {
"$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
)
}
}
}
}
/**
* Assign an AppOrigin to an EntryInfo,
* Only if [customFieldsAllowed] is true
*/
fun EntryInfo.setAppOrigin(appOrigin: AppOrigin?, customFieldsAllowed: Boolean) {
appOrigin?.androidOrigins?.forEach { appIdentifier ->
setApplicationId(appIdentifier.packageName, appIdentifier.fingerprint)
}
appOrigin?.webOrigins?.forEach { webOrigin ->
setWebDomain(webOrigin.origin, null, customFieldsAllowed)
}
}
}

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

@@ -22,18 +22,27 @@ package com.kunzisoft.keepass.model
import android.os.Parcel
import android.os.ParcelUuid
import android.os.Parcelable
import com.kunzisoft.keepass.database.element.*
import com.kunzisoft.keepass.database.element.Attachment
import com.kunzisoft.keepass.database.element.Database
import com.kunzisoft.keepass.database.element.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.AppOriginEntryField.setAppOrigin
import com.kunzisoft.keepass.model.AppOriginEntryField.setApplicationId
import com.kunzisoft.keepass.model.AppOriginEntryField.setWebDomain
import com.kunzisoft.keepass.model.CreditCardEntryFields.setCreditCard
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
import com.kunzisoft.keepass.utils.writeBooleanCompat
import java.util.*
import java.util.Locale
import java.util.UUID
class EntryInfo : NodeInfo {
@@ -49,6 +58,8 @@ class EntryInfo : NodeInfo {
var attachments: MutableList<Attachment> = mutableListOf()
var autoType: AutoType = AutoType()
var otpModel: OtpModel? = null
var passkey: Passkey? = null
var appOrigin: AppOrigin? = null
var isTemplate: Boolean = false
constructor() : super()
@@ -68,6 +79,8 @@ class EntryInfo : NodeInfo {
parcel.readListCompat(attachments)
autoType = parcel.readParcelableCompat() ?: autoType
otpModel = parcel.readParcelableCompat() ?: otpModel
passkey = parcel.readParcelableCompat() ?: passkey
appOrigin = parcel.readParcelableCompat() ?: appOrigin
isTemplate = parcel.readBooleanCompat()
}
@@ -89,15 +102,16 @@ class EntryInfo : NodeInfo {
parcel.writeList(attachments)
parcel.writeParcelable(autoType, flags)
parcel.writeParcelable(otpModel, flags)
parcel.writeParcelable(passkey, flags)
parcel.writeParcelable(appOrigin, flags)
parcel.writeBooleanCompat(isTemplate)
}
fun containsCustomFieldsProtected(): Boolean {
return customFields.any { it.protectedValue.isProtected }
}
fun containsCustomFieldsNotProtected(): Boolean {
return customFields.any { !it.protectedValue.isProtected }
fun getCustomFieldsForFilling(): List<Field> {
return customFields.filter {
!it.isOtpExclusion()
&& !it.isPasskeyExclusion()
}
}
fun containsCustomField(label: String): Boolean {
@@ -113,133 +127,98 @@ class EntryInfo : NodeInfo {
return customFields.lastOrNull { it.name == label }?.protectedValue?.toString() ?: ""
}
// 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
/**
* 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 {
protectedValue = field.protectedValue
}
// 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))
}
private fun containsDomainOrApplicationId(search: String): Boolean {
if (url.contains(search))
return true
return customFields.find {
it.protectedValue.stringValue.contains(search)
} != null
} ?: customFields.add(field)
}
/**
* Add searchInfo to current EntryInfo, return true if new data, false if no modification
* Add a field to the custom fields list with a suffix position,
* replace if name already exists
*/
fun saveSearchInfo(database: Database?, searchInfo: SearchInfo): Boolean {
var modification = false
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)
}
}
/**
* Capitalize and remove suffix of a title
*/
fun String.toTitle(): String {
return this.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
}
/**
* Add searchInfo to current EntryInfo
*/
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.toString().toTitle()
}
return modification
}
/**
* Capitalize and remove suffix of web domain to create a title
* Add registerInfo to current EntryInfo
*/
private fun searchInfoToTitle(searchInfo: SearchInfo): String {
val webDomain = searchInfo.webDomain
return webDomain?.substring(0, webDomain.lastIndexOf('.'))?.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
} ?: searchInfo.toString()
}
fun saveRegisterInfo(database: Database?, registerInfo: RegisterInfo) {
registerInfo.searchInfo.let {
title = searchInfoToTitle(it)
}
registerInfo.username?.let {
username = it
}
registerInfo.password?.let {
password = it
}
if (database?.allowEntryCustomFields() == true) {
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)))
}
saveSearchInfo(database, registerInfo.searchInfo)
registerInfo.username?.let { username = it }
registerInfo.password?.let { password = it }
setCreditCard(registerInfo.creditCard)
setPasskey(registerInfo.passkey)
setAppOrigin(
registerInfo.appOrigin,
database?.allowEntryCustomFields() == true
)
if (title.isEmpty()) {
title = registerInfo.toString().toTitle()
}
}
@@ -268,6 +247,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 (appOrigin != other.appOrigin) return false
if (isTemplate != other.isTemplate) return false
return true
@@ -287,6 +268,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 + (appOrigin?.hashCode() ?: 0)
result = 31 * result + isTemplate.hashCode()
return result
}
@@ -294,8 +277,12 @@ class EntryInfo : NodeInfo {
companion object {
const val WEB_DOMAIN_FIELD_NAME = "URL"
const val APPLICATION_ID_FIELD_NAME = "AndroidApp"
/**
* Create a field name suffix depending on the field position
*/
fun suffixFieldNamePosition(position: Int): String {
return if (position > 0) "_$position" else ""
}
@JvmField
val CREATOR: Parcelable.Creator<EntryInfo> = object : Parcelable.Creator<EntryInfo> {

View File

@@ -0,0 +1,32 @@
/*
* 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 Passkey(
val username: String,
val privateKeyPem: String,
val credentialId: String,
val userHandle: String,
val relyingParty: String
): Parcelable

View File

@@ -0,0 +1,139 @@
package com.kunzisoft.keepass.model
import com.kunzisoft.keepass.database.element.Field
import com.kunzisoft.keepass.database.element.security.ProtectedString
object PasskeyEntryFields {
// field names from KeypassXC are used
const val FIELD_USERNAME = "KPEX_PASSKEY_USERNAME"
const val FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM"
const val FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID"
const val FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE"
const val FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY"
const val PASSKEY_FIELD = "Passkey"
const val PASSKEY_TAG = "Passkey"
/**
* Parse fields of an entry to retrieve a Passkey
*/
fun parseFields(getField: (id: String) -> String?): Passkey? {
val usernameField = getField(FIELD_USERNAME)
val privateKeyField = getField(FIELD_PRIVATE_KEY)
val credentialIdField = getField(FIELD_CREDENTIAL_ID)
val userHandleField = getField(FIELD_USER_HANDLE)
val relyingPartyField = getField(FIELD_RELYING_PARTY)
if (usernameField == null
|| privateKeyField == null
|| credentialIdField == null
|| userHandleField == null
|| relyingPartyField == null)
return null
return Passkey(
username = usernameField,
privateKeyPem = privateKeyField,
credentialId = credentialIdField,
userHandle = userHandleField,
relyingParty = relyingPartyField
)
}
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_CREDENTIAL_ID,
ProtectedString(enableProtection = true, passkey.credentialId)
)
)
addOrReplaceField(
Field(
FIELD_USER_HANDLE,
ProtectedString(enableProtection = true, passkey.userHandle)
)
)
addOrReplaceField(
Field(
FIELD_RELYING_PARTY,
ProtectedString(enableProtection = false, passkey.relyingParty)
)
)
}
}
/**
* Build Passkey field from a Passkey
*/
fun buildPasskeyField(passkey: Passkey): Field {
return Field(
name = PASSKEY_FIELD,
value = ProtectedString(
enableProtection = false,
string = passkey.relyingParty
)
)
}
/**
* Build new generated fields in a new list from [fieldsToParse] in parameter,
* Remove parameters fields use to generate auto fields
*/
fun generateAutoFields(fieldsToParse: List<Field>): MutableList<Field> {
val newCustomFields: MutableList<Field> = ArrayList(fieldsToParse)
// Remove parameter fields
val usernameField = Field(FIELD_USERNAME)
val privateKeyField = Field(FIELD_PRIVATE_KEY)
val credentialIdField = Field(FIELD_CREDENTIAL_ID)
val userHandleField = Field(FIELD_USER_HANDLE)
val relyingPartyField = Field(FIELD_RELYING_PARTY)
newCustomFields.remove(usernameField)
newCustomFields.remove(privateKeyField)
newCustomFields.remove(credentialIdField)
newCustomFields.remove(userHandleField)
newCustomFields.remove(relyingPartyField)
// Empty auto generated OTP Token field
if (fieldsToParse.contains(usernameField)
|| fieldsToParse.contains(privateKeyField)
|| fieldsToParse.contains(credentialIdField)
|| fieldsToParse.contains(userHandleField)
|| fieldsToParse.contains(relyingPartyField)
)
newCustomFields.add(
Field(
name = PASSKEY_FIELD,
value = ProtectedString(enableProtection = false)
)
)
return newCustomFields
}
/**
* Field ignored for a search or a form filling
*/
fun Field.isPasskeyExclusion(): Boolean {
return when(name) {
PASSKEY_FIELD -> true
FIELD_USERNAME -> true
FIELD_PRIVATE_KEY -> true
FIELD_CREDENTIAL_ID -> true
FIELD_USER_HANDLE -> true
FIELD_RELYING_PARTY -> true
else -> false
}
}
}

View File

@@ -1,32 +1,56 @@
package com.kunzisoft.keepass.model
import android.content.res.Resources
import android.os.Parcel
import android.os.Parcelable
import com.kunzisoft.keepass.utils.ObjectNameResource
import com.kunzisoft.keepass.utils.readParcelableCompat
data class RegisterInfo(val searchInfo: SearchInfo,
val username: String?,
val password: String?,
val creditCard: CreditCard?): Parcelable {
data class RegisterInfo(
val searchInfo: SearchInfo,
val username: String? = null,
val password: String? = null,
val creditCard: CreditCard? = null,
val passkey: Passkey? = null,
val appOrigin: AppOrigin? = null
) : ObjectNameResource, Parcelable {
constructor(parcel: Parcel) : this(
parcel.readParcelableCompat() ?: SearchInfo(),
parcel.readString() ?: "",
parcel.readString() ?: "",
parcel.readParcelableCompat()) {
}
searchInfo = parcel.readParcelableCompat() ?: SearchInfo(),
username = parcel.readString(),
password = parcel.readString(),
creditCard = parcel.readParcelableCompat(),
passkey = parcel.readParcelableCompat(),
appOrigin = parcel.readParcelableCompat()
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeParcelable(searchInfo, flags)
parcel.writeString(username)
parcel.writeString(password)
parcel.writeParcelable(creditCard, flags)
parcel.writeParcelable(passkey, flags)
parcel.writeParcelable(appOrigin, flags)
}
override fun describeContents(): Int {
return 0
}
override fun getName(resources: Resources): String {
return username
?: passkey?.relyingParty
?: appOrigin?.toName()
?: searchInfo.getName(resources)
}
override fun toString(): String {
return username
?: passkey?.relyingParty
?: appOrigin?.toName()
?: searchInfo.toString()
}
companion object CREATOR : Parcelable.Creator<RegisterInfo> {
override fun createFromParcel(parcel: Parcel): RegisterInfo {
return RegisterInfo(parcel)

View File

@@ -4,6 +4,7 @@ import android.content.res.Resources
import android.net.Uri
import android.os.Parcel
import android.os.Parcelable
import com.kunzisoft.keepass.database.search.SearchParameters
import com.kunzisoft.keepass.otp.OtpEntryFields
import com.kunzisoft.keepass.utils.ObjectNameResource
import com.kunzisoft.keepass.utils.readBooleanCompat
@@ -11,6 +12,7 @@ import com.kunzisoft.keepass.utils.writeBooleanCompat
class SearchInfo : ObjectNameResource, Parcelable {
var manualSelection: Boolean = false
var tag: String? = null
var applicationId: String? = null
set(value) {
field = when {
@@ -33,26 +35,33 @@ class SearchInfo : ObjectNameResource, Parcelable {
get() {
return if (webDomain == null) null else field
}
var relyingParty: String? = null
var otpString: String? = null
constructor()
constructor(toCopy: SearchInfo?) {
manualSelection = toCopy?.manualSelection ?: manualSelection
tag = toCopy?.tag
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
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
}
@@ -63,9 +72,11 @@ class SearchInfo : ObjectNameResource, Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeBooleanCompat(manualSelection)
parcel.writeString(tag ?: "")
parcel.writeString(applicationId ?: "")
parcel.writeString(webDomain ?: "")
parcel.writeString(webScheme ?: "")
parcel.writeString(relyingParty ?: "")
parcel.writeString(otpString ?: "")
}
@@ -79,24 +90,54 @@ class SearchInfo : ObjectNameResource, Parcelable {
}
fun containsOnlyNullValues(): Boolean {
return applicationId == null
return tag == null
&& applicationId == null
&& webDomain == null
&& webScheme == null
&& relyingParty == null
&& otpString == null
}
fun isASearchByDomain(): Boolean {
private fun isADomainSearch(): Boolean {
return toString() == webDomain && webDomain != null
}
}
var isAPasskeySearch: Boolean = false
var query: String? = null
fun buildSearchParameters(): SearchParameters {
return SearchParameters().apply {
searchQuery = query ?: this@SearchInfo.toString()
allowEmptyQuery = false
searchInTitles = !isAPasskeySearch
searchInUsernames = false
searchInPasswords = false
searchInUrls = !isAPasskeySearch
searchByDomain = isADomainSearch()
searchInNotes = false
searchInOTP = false
searchInOther = true
searchInUUIDs = false
searchInTags = false
searchInRelyingParty = isAPasskeySearch
searchInCurrentGroup = false
searchInSearchableGroup = true
searchInRecycleBin = false
searchInTemplates = false
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SearchInfo) return false
if (manualSelection != other.manualSelection) return false
if (tag != other.tag) return false
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
@@ -104,15 +145,17 @@ class SearchInfo : ObjectNameResource, Parcelable {
override fun hashCode(): Int {
var result = manualSelection.hashCode()
result = 31 * result + (tag?.hashCode() ?: 0)
result = 31 * result + (applicationId?.hashCode() ?: 0)
result = 31 * result + (webDomain?.hashCode() ?: 0)
result = 31 * result + (webScheme?.hashCode() ?: 0)
result = 31 * result + (relyingParty?.hashCode() ?: 0)
result = 31 * result + (otpString?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return otpString ?: webDomain ?: applicationId ?: ""
return otpString ?: webDomain ?: applicationId ?: relyingParty ?: 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
@@ -486,4 +506,28 @@ object OtpEntryFields {
newCustomFields.add(Field(OTP_TOKEN_FIELD))
return newCustomFields
}
/**
* Field ignored for a search or a form filling
*/
fun Field.isOtpExclusion(): Boolean {
return when(name) {
OTP_FIELD -> true
TOTP_SEED_FIELD -> true
TOTP_SETTING_FIELD -> true
HMACOTP_SECRET_FIELD -> true
HMACOTP_SECRET_HEX_FIELD -> true
HMACOTP_SECRET_BASE32_FIELD -> true
HMACOTP_SECRET_BASE64_FIELD -> true
HMACOTP_SECRET_COUNTER_FIELD -> true
TIMEOTP_SECRET_FIELD -> true
TIMEOTP_SECRET_HEX_FIELD -> true
TIMEOTP_SECRET_BASE32_FIELD -> true
TIMEOTP_SECRET_BASE64_FIELD -> true
TIMEOTP_LENGTH_FIELD -> true
TIMEOTP_PERIOD_FIELD -> true
TIMEOTP_ALGORITHM_FIELD -> true
else -> false
}
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2019 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.utils
import java.nio.ByteBuffer
import java.util.Locale
import java.util.UUID
object UUIDUtils {
/**
* Specific UUID string format for KeePass database
*/
fun UUID.asHexString(): String? {
try {
val buf = uuidTo16Bytes(this)
val len = buf.size
if (len == 0) {
return ""
}
val sb = StringBuilder()
var bt: Short
var high: Char
var low: Char
for (b in buf) {
bt = (b.toInt() and 0xFF).toShort()
high = (bt.toInt() ushr 4).toChar()
low = (bt.toInt() and 0x0F).toChar()
sb.append(byteToChar(high))
sb.append(byteToChar(low))
}
return sb.toString()
} catch (e: Exception) {
return null
}
}
/**
* From a specific UUID KeePass database string format,
* Note : For a standard UUID string format, use UUID.fromString()
*/
fun String.asUUID(): UUID? {
if (this.length != 32) return null
val charArray = this.lowercase(Locale.getDefault()).toCharArray()
val leastSignificantChars = CharArray(16)
val mostSignificantChars = CharArray(16)
var i = 31
while (i >= 0) {
if (i >= 16) {
mostSignificantChars[32 - i] = charArray[i]
mostSignificantChars[31 - i] = charArray[i - 1]
} else {
leastSignificantChars[16 - i] = charArray[i]
leastSignificantChars[15 - i] = charArray[i - 1]
}
i = i - 2
}
val standardUUIDString = StringBuilder()
standardUUIDString.append(leastSignificantChars)
standardUUIDString.append(mostSignificantChars)
standardUUIDString.insert(8, '-')
standardUUIDString.insert(13, '-')
standardUUIDString.insert(18, '-')
standardUUIDString.insert(23, '-')
return try {
UUID.fromString(standardUUIDString.toString())
} catch (e: Exception) {
null
}
}
fun ByteArray.asUUID(): UUID {
val bb = ByteBuffer.wrap(this)
val firstLong = bb.getLong()
val secondLong = bb.getLong()
return UUID(firstLong, secondLong)
}
fun UUID.asBytes(): ByteArray {
return ByteBuffer.allocate(16).apply {
putLong(mostSignificantBits)
putLong(leastSignificantBits)
}.array()
}
// Use short to represent unsigned byte
private fun byteToChar(bt: Char): Char {
return if (bt.code >= 10) {
('A'.code + bt.code - 10).toChar()
} else {
('0'.code + bt.code).toChar()
}
}
}

View File

@@ -1,101 +0,0 @@
/*
* Copyright 2019 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.utils;
import java.util.UUID;
import static com.kunzisoft.keepass.utils.StreamBytesUtilsKt.uuidTo16Bytes;
import org.jetbrains.annotations.Nullable;
public class UuidUtil {
public static @Nullable String toHexString(@Nullable UUID uuid) {
if (uuid == null) { return null; }
try {
byte[] buf = uuidTo16Bytes(uuid);
int len = buf.length;
if (len == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
short bt;
char high, low;
for (byte b : buf) {
bt = (short) (b & 0xFF);
high = (char) (bt >>> 4);
low = (char) (bt & 0x0F);
sb.append(byteToChar(high));
sb.append(byteToChar(low));
}
return sb.toString();
} catch (Exception e) {
return null;
}
}
public static @Nullable UUID fromHexString(@Nullable String hexString) {
if (hexString == null)
return null;
if (hexString.length() != 32)
return null;
char[] charArray = hexString.toLowerCase().toCharArray();
char[] leastSignificantChars = new char[16];
char[] mostSignificantChars = new char[16];
for (int i = 31; i >= 0; i = i-2) {
if (i >= 16) {
mostSignificantChars[32-i] = charArray[i];
mostSignificantChars[31-i] = charArray[i-1];
} else {
leastSignificantChars[16-i] = charArray[i];
leastSignificantChars[15-i] = charArray[i-1];
}
}
StringBuilder standardUUIDString = new StringBuilder();
standardUUIDString.append(leastSignificantChars);
standardUUIDString.append(mostSignificantChars);
standardUUIDString.insert(8, '-');
standardUUIDString.insert(13, '-');
standardUUIDString.insert(18, '-');
standardUUIDString.insert(23, '-');
try {
return UUID.fromString(standardUUIDString.toString());
} catch (Exception e) {
return null;
}
}
// Use short to represent unsigned byte
private static char byteToChar(char bt) {
if (bt >= 10) {
return (char)('A' + bt - 10);
}
else {
return (char)('0' + bt);
}
}
}

View File

@@ -1,15 +1,49 @@
/*
* 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.tests.utils
import com.kunzisoft.keepass.utils.UuidUtil
import com.kunzisoft.keepass.utils.UUIDUtils.asBytes
import com.kunzisoft.keepass.utils.UUIDUtils.asHexString
import com.kunzisoft.keepass.utils.UUIDUtils.asUUID
import junit.framework.TestCase
import java.util.*
import java.util.UUID
class UUIDTest: TestCase() {
fun testUUID() {
fun testUUIDHexString() {
val randomUUID = UUID.randomUUID()
val hexStringUUID = UuidUtil.toHexString(randomUUID)
val retrievedUUID = UuidUtil.fromHexString(hexStringUUID)
val hexStringUUID = randomUUID.asHexString()
val retrievedUUID = hexStringUUID?.asUUID()
assertEquals(randomUUID, retrievedUUID)
}
fun testUUIDString() {
val staticUUID = "4be0643f-1d98-573b-97cd-ca98a65347dd"
val stringUUID = UUID.fromString(staticUUID).asBytes().asUUID().toString()
assertEquals(staticUUID, stringUUID)
}
fun testUUIDBytes() {
val randomUUID = UUID.randomUUID()
val byteArrayUUID = randomUUID.asBytes()
val retrievedUUID = byteArrayUUID.asUUID()
assertEquals(randomUUID, retrievedUUID)
}
}