feat: JSON parser to manage Privileged apps

This commit is contained in:
J-Jamet
2025-09-03 14:25:53 +02:00
parent 8924254c25
commit 988b18b515
10 changed files with 504 additions and 98 deletions

View File

@@ -234,7 +234,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
Log.d(TAG, "Launch passkey selection")
retrievePasskeyUsageRequestParameters(
intent = intent,
assetManager = assets
context = applicationContext
) { usageParameters ->
// Save the requested parameters
mUsageParameters = usageParameters
@@ -304,7 +304,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
Log.d(TAG, "Launch passkey registration")
retrievePasskeyCreationRequestParameters(
intent = intent,
assetManager = assets,
context = applicationContext,
passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters ->
// Save the requested parameters
mPasskey = passkey

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2025 AOSP modified by Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
* KeePassDX is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* KeePassDX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.credentialprovider.passkey.data
import android.os.Build
import android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
/**
* Represents an Android privileged app, based on AOSP code
*/
@Parcelize
data class AndroidPrivilegedApp(
val packageName: String,
val fingerprints: Set<String>
): Parcelable {
override fun toString(): String {
return "$packageName ($fingerprints)"
}
companion object {
private const val PACKAGE_NAME_KEY = "package_name"
private const val SIGNATURES_KEY = "signatures"
private const val FINGERPRINT_KEY = "cert_fingerprint_sha256"
private const val BUILD_KEY = "build"
private const val USER_DEBUG_KEY = "userdebug"
private const val TYPE_KEY = "type"
private const val APP_INFO_KEY = "info"
private const val ANDROID_TYPE_KEY = "android"
private const val USER_BUILD_TYPE = "userdebug"
private const val APPS_KEY = "apps"
@JvmStatic
fun extractPrivilegedApps(jsonObject: JSONObject): List<AndroidPrivilegedApp> {
val apps = mutableListOf<AndroidPrivilegedApp>()
if (!jsonObject.has(APPS_KEY)) {
return apps
}
val appsJsonArray = jsonObject.getJSONArray(APPS_KEY)
for (i in 0 until appsJsonArray.length()) {
try {
val appJsonObject = appsJsonArray.getJSONObject(i)
if (appJsonObject.getString(TYPE_KEY) != ANDROID_TYPE_KEY) {
continue
}
if (!appJsonObject.has(APP_INFO_KEY)) {
continue
}
apps.add(
createFromJSONObject(
appJsonObject.getJSONObject(APP_INFO_KEY)
)
)
} catch (e: JSONException) {
Log.e(AndroidPrivilegedApp::class.simpleName, "Error parsing privileged app", e)
}
}
return apps
}
/**
* Creates an AndroidPrivilegedApp object from a JSONObject.
*/
@JvmStatic
private fun createFromJSONObject(
appInfoJsonObject: JSONObject,
filterUserDebug: Boolean = true
): AndroidPrivilegedApp {
val signaturesJson = appInfoJsonObject.getJSONArray(SIGNATURES_KEY)
val fingerprints = mutableSetOf<String>()
for (j in 0 until signaturesJson.length()) {
if (filterUserDebug) {
if (USER_DEBUG_KEY == signaturesJson.getJSONObject(j)
.optString(BUILD_KEY) && USER_BUILD_TYPE != Build.TYPE
) {
continue
}
}
fingerprints.add(signaturesJson.getJSONObject(j).getString(FINGERPRINT_KEY))
}
return AndroidPrivilegedApp(
packageName = appInfoJsonObject.getString(PACKAGE_NAME_KEY),
fingerprints = fingerprints
)
}
/**
* Creates a JSONObject from a list of AndroidPrivilegedApp objects.
* The structure will be similar to what `extractPrivilegedApps` expects.
*
* @param privilegedApps The list of AndroidPrivilegedApp objects.
* @return A JSONObject representing the list.
*/
@JvmStatic
fun toJsonObject(privilegedApps: List<AndroidPrivilegedApp>): JSONObject {
val rootJsonObject = JSONObject()
val appsJsonArray = JSONArray()
for (app in privilegedApps) {
val appInfoObject = JSONObject()
appInfoObject.put(PACKAGE_NAME_KEY, app.packageName)
val signaturesArray = JSONArray()
for (fingerprint in app.fingerprints) {
val signatureObject = JSONObject()
signatureObject.put(FINGERPRINT_KEY, fingerprint)
// If needed: signatureObject.put(BUILD_KEY, "user")
signaturesArray.put(signatureObject)
}
appInfoObject.put(SIGNATURES_KEY, signaturesArray)
val appContainerObject = JSONObject()
appContainerObject.put(TYPE_KEY, ANDROID_TYPE_KEY)
appContainerObject.put(APP_INFO_KEY, appInfoObject)
appsJsonArray.put(appContainerObject)
}
rootJsonObject.put(APPS_KEY, appsJsonArray)
return rootJsonObject
}
}
}

View File

@@ -20,8 +20,8 @@
package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.AssetManager
import android.os.Build
import android.os.Bundle
import android.os.ParcelUuid
@@ -42,7 +42,6 @@ import androidx.credentials.provider.ProviderGetCredentialRequest
import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode
import com.kunzisoft.encrypt.Signature
import com.kunzisoft.encrypt.Signature.getApplicationFingerprints
import com.kunzisoft.keepass.BuildConfig
import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAssertionResponse
import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAttestationResponse
import com.kunzisoft.keepass.credentialprovider.passkey.data.Cbor
@@ -54,6 +53,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredential
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.getOriginFromPrivilegedAllowLists
import com.kunzisoft.keepass.model.AndroidOrigin
import com.kunzisoft.keepass.model.AppOrigin
import com.kunzisoft.keepass.model.EntryInfo
@@ -328,61 +328,20 @@ object PasskeyHelper {
return request.credentialOptions[0] as GetPublicKeyCredentialOption
}
/**
* Get the origin from a privileged allow list,
* [fileName] is the name of the file in assets containing the origin list as JSON
*/
private fun getOriginFromPrivilegedAllowListFile(
callingAppInfo: CallingAppInfo,
assets: AssetManager,
fileName: String
): String? {
val privilegedAllowList = assets.open(fileName).bufferedReader().use {
it.readText()
}
return callingAppInfo.getOrigin(privilegedAllowList)?.removeSuffix("/")
}
/**
* Get the origin from the predefined privileged allow lists
*/
private fun getOriginFromPrivilegedAllowLists(
callingAppInfo: CallingAppInfo,
assets: AssetManager
): String? {
return try {
// TODO add the manual privileged apps
// Check the community apps first
getOriginFromPrivilegedAllowListFile(
callingAppInfo = callingAppInfo,
assets = assets,
fileName = FILE_NAME_PRIVILEGED_APPS_COMMUNITY
)
} catch (e: Exception) {
// Then the Google list if allowed
if (BuildConfig.CLOSED_STORE) {
// http://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json
getOriginFromPrivilegedAllowListFile(
callingAppInfo = callingAppInfo,
assets = assets,
fileName = FILE_NAME_PRIVILEGED_APPS_GOOGLE
)
} else {
throw e
}
}
}
/**
* Utility method to retrieve the origin asynchronously,
* checks for the presence of the application in the privilege list of the passkeys_privileged_apps_google.json file,
* checks for the presence of the application in the privilege lists
*
* @param providedClientDataHash Client data hash precalculated by the system
* @param callingAppInfo CallingAppInfo to verify and retrieve the specific Origin
* @param context Context for file operations.
* call [onOriginRetrieved] if the origin is already calculated by the system and in the privileged list, return the clientDataHash
* call [onOriginNotRetrieved] if the origin is not retrieved from the system, return a new Android Origin
*/
suspend fun getOrigin(
providedClientDataHash: ByteArray?,
callingAppInfo: CallingAppInfo?,
assets: AssetManager,
context: Context,
onOriginRetrieved: (appOrigin: AppOrigin, clientDataHash: ByteArray) -> Unit,
onOriginNotRetrieved: (appOrigin: AppOrigin, androidOriginString: String) -> Unit
) {
@@ -392,7 +351,7 @@ object PasskeyHelper {
withContext(Dispatchers.IO) {
// For trusted browsers like Chrome and Firefox
val callOrigin = getOriginFromPrivilegedAllowLists(callingAppInfo, assets)
val callOrigin = getOriginFromPrivilegedAllowLists(callingAppInfo, context)
// Build the default Android origin
val androidOrigin = AndroidOrigin(
@@ -436,12 +395,12 @@ object PasskeyHelper {
/**
* Utility method to create a passkey and the associated creation request parameters
* [intent] allows to retrieve the request
* [assetManager] has been transferred to the origin manager to manage package verification files
* [context] context to manage package verification files
* [passkeyCreated] is called asynchronously when the passkey has been created
*/
suspend fun retrievePasskeyCreationRequestParameters(
intent: Intent,
assetManager: AssetManager,
context: Context,
passkeyCreated: (Passkey, AppOrigin?, PublicKeyCredentialCreationParameters) -> Unit
) {
val createCredentialRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
@@ -476,7 +435,7 @@ object PasskeyHelper {
getOrigin(
providedClientDataHash = clientDataHash,
callingAppInfo = callingAppInfo,
assets = assetManager,
context = context,
onOriginRetrieved = { appInfoToStore, clientDataHash ->
passkeyCreated.invoke(
passkey,
@@ -546,12 +505,12 @@ object PasskeyHelper {
/**
* Utility method to use a passkey and create the associated usage request parameters
* [intent] allows to retrieve the request
* [assetManager] has been transferred to the origin manager to manage package verification files
* [context] context to manage package verification files
* [result] is called asynchronously after the creation of PublicKeyCredentialUsageParameters, the origin associated with it may or may not be verified
*/
suspend fun retrievePasskeyUsageRequestParameters(
intent: Intent,
assetManager: AssetManager,
context: Context,
result: (PublicKeyCredentialUsageParameters) -> Unit
) {
val getCredentialRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
@@ -566,7 +525,7 @@ object PasskeyHelper {
getOrigin(
providedClientDataHash = clientDataHash,
callingAppInfo = callingAppInfo,
assets = assetManager,
context = context,
onOriginRetrieved = { appOrigin, clientDataHash ->
result.invoke(
PublicKeyCredentialUsageParameters(
@@ -642,7 +601,4 @@ object PasskeyHelper {
}
private const val BACKUP_ELIGIBILITY = true
private const val FILE_NAME_PRIVILEGED_APPS_COMMUNITY = "passkeys_privileged_apps_community.json"
private const val FILE_NAME_PRIVILEGED_APPS_GOOGLE = "passkeys_privileged_apps_google.json"
}

View File

@@ -0,0 +1,206 @@
/*
* Copyright 2025 Jeremy Jamet / Kunzisoft.
*
* This file is part of KeePassDX.
*
* KeePassDX is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* KeePassDX is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with KeePassDX. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.content.Context
import android.util.Log
import androidx.credentials.provider.CallingAppInfo
import com.kunzisoft.keepass.BuildConfig
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import org.json.JSONObject
import java.io.File
import java.io.InputStream
object PrivilegedAllowLists {
private const val FILE_NAME_PRIVILEGED_APPS_CUSTOM = "passkeys_privileged_apps_custom.json"
private const val FILE_NAME_PRIVILEGED_APPS_COMMUNITY = "passkeys_privileged_apps_community.json"
private const val FILE_NAME_PRIVILEGED_APPS_GOOGLE = "passkeys_privileged_apps_google.json"
private fun retrieveContentFromStream(
inputStream: InputStream,
): String {
return inputStream.use { fileInputStream ->
fileInputStream.bufferedReader(Charsets.UTF_8).readText()
}
}
/**
* Get the origin from a predefined privileged allow list
*
* @param callingAppInfo CallingAppInfo to verify and retrieve the specific Origin
* @param inputStream File input stream containing the origin list as JSON
*/
private fun getOriginFromPrivilegedAllowListStream(
callingAppInfo: CallingAppInfo,
inputStream: InputStream
): String? {
val privilegedAllowList = retrieveContentFromStream(inputStream)
return callingAppInfo.getOrigin(privilegedAllowList)?.removeSuffix("/")
}
/**
* Get the origin from the predefined privileged allow lists
*
* @param callingAppInfo CallingAppInfo to verify and retrieve the specific Origin
* @param context Context for file operations.
*/
fun getOriginFromPrivilegedAllowLists(
callingAppInfo: CallingAppInfo,
context: Context
): String? {
return try {
try {
// Check the custom apps first
getOriginFromPrivilegedAllowListStream(
callingAppInfo = callingAppInfo,
File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM)
.inputStream()
)
} catch (_: Exception) {
// Then the community apps list
getOriginFromPrivilegedAllowListStream(
callingAppInfo = callingAppInfo,
inputStream = context.assets.open(FILE_NAME_PRIVILEGED_APPS_COMMUNITY)
)
}
} catch (e: Exception) {
// Then the Google list if allowed
if (BuildConfig.CLOSED_STORE) {
// http://www.gstatic.com/gpm-passkeys-privileged-apps/apps.json
getOriginFromPrivilegedAllowListStream(
callingAppInfo = callingAppInfo,
inputStream = context.assets.open(FILE_NAME_PRIVILEGED_APPS_GOOGLE)
)
} else {
throw e
}
}
}
/**
* Retrieves a list of predefined AndroidPrivilegedApp objects from an asset JSON file.
*
* @param inputStream File input stream containing the origin list as JSON
*/
private fun retrievePrivilegedApps(
inputStream: InputStream
): List<AndroidPrivilegedApp> {
val jsonObject = JSONObject(retrieveContentFromStream(inputStream))
return AndroidPrivilegedApp.extractPrivilegedApps(jsonObject)
}
/**
* Retrieves a list of predefined AndroidPrivilegedApp objects from a context
*
* @param context Context for file operations.
*/
fun retrievePredefinedPrivilegedApps(
context: Context
): List<AndroidPrivilegedApp> {
return try {
val predefinedApps = mutableListOf<AndroidPrivilegedApp>()
predefinedApps.addAll(retrievePrivilegedApps(context.assets.open(FILE_NAME_PRIVILEGED_APPS_COMMUNITY)))
if (BuildConfig.CLOSED_STORE) {
predefinedApps.addAll(retrievePrivilegedApps(context.assets.open(FILE_NAME_PRIVILEGED_APPS_GOOGLE)))
}
predefinedApps
} catch (e: Exception) {
Log.e(PrivilegedAllowLists::class.simpleName, "Error retrieving privileged apps", e)
emptyList()
}
}
/**
* Retrieves a list of AndroidPrivilegedApp objects from the custom JSON file.
*
* @param context Context for file operations.
*/
fun retrieveCustomPrivilegedApps(
context: Context
): List<AndroidPrivilegedApp> {
return try {
retrievePrivilegedApps(File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM).inputStream())
} catch (e: Exception) {
Log.i(PrivilegedAllowLists::class.simpleName, "No custom privileged apps", e)
emptyList()
}
}
/**
* Retrieves a list of all predefined and custom AndroidPrivilegedApp objects.
*/
fun retrieveAllPrivilegedApps(
context: Context
): List<AndroidPrivilegedApp> {
return retrievePredefinedPrivilegedApps(context) + retrieveCustomPrivilegedApps(context)
}
/**
* Saves a list of custom AndroidPrivilegedApp objects to a JSON file.
*
* @param context Context for file operations.
* @param privilegedApps The list of apps to save.
* @return True if saving was successful, false otherwise.
*/
fun saveCustomPrivilegedApps(context: Context, privilegedApps: List<AndroidPrivilegedApp>): Boolean {
return try {
val jsonToSave = AndroidPrivilegedApp.toJsonObject(privilegedApps)
val file = File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM)
// Delete existing file before writing to ensure atomicity if needed
if (file.exists()) {
file.delete()
}
file.outputStream().use { fileOutputStream ->
fileOutputStream.write(
jsonToSave
.toString(4) // toString(4) for pretty print
.toByteArray(Charsets.UTF_8)
)
}
true
} catch (e: Exception) {
Log.e(PrivilegedAllowLists::class.simpleName, "Error saving privileged apps", e)
false
}
}
/**
* Deletes the custom JSON file.
*
* @param context Context for file operations.
* @return True if deletion was successful or file didn't exist, false otherwise.
*/
fun deletePrivilegedAppsFile(context: Context): Boolean {
return try {
val file = File(context.filesDir, FILE_NAME_PRIVILEGED_APPS_CUSTOM)
if (file.exists()) {
file.delete()
} else {
true // File didn't exist, so considered "successfully deleted"
}
} catch (e: SecurityException) {
Log.e(PrivilegedAllowLists::class.simpleName, "Error deleting privileged apps file", e)
false
}
}
}

View File

@@ -23,39 +23,55 @@ import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.annotation.RequiresApi
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.model.AndroidOrigin
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.settings.preferencedialogfragment.adapter.ListSelectionItemAdapter
import com.kunzisoft.keepass.utils.AppUtil.getInstalledBrowsersWithSignatures
import com.kunzisoft.keepass.settings.preferencedialogfragment.viewmodel.PasskeysPrivilegedAppsViewModel
import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeysPrivilegedAppsPreferenceDialogFragmentCompat
: InputPreferenceDialogFragmentCompat() {
private var mAdapter = ListSelectionItemAdapter<AndroidOrigin>()
private var mListBrowsers: List<AndroidOrigin> = mutableListOf()
private var mAdapter = ListSelectionItemAdapter<AndroidPrivilegedApp>()
private val passkeysPrivilegedAppsViewModel : PasskeysPrivilegedAppsViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mListBrowsers = getInstalledBrowsersWithSignatures(requireContext())
// TODO filter with current privileged apps
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
passkeysPrivilegedAppsViewModel.retrievePrivilegedAppsToSelect()
passkeysPrivilegedAppsViewModel.uiState.collect { uiState ->
when(uiState) {
is PasskeysPrivilegedAppsViewModel.UiState.Loading -> {}
is PasskeysPrivilegedAppsViewModel.UiState.OnPrivilegedAppsToSelectRetrieved -> {
mAdapter.setItems(uiState.privilegedApps)
}
}
}
}
}
}
override fun onBindDialogView(view: View) {
super.onBindDialogView(view)
view.findViewById<RecyclerView>(R.id.pref_dialog_list).apply {
layoutManager = LinearLayoutManager(context)
adapter = mAdapter.apply {
setItems(mListBrowsers)
}
adapter = mAdapter
}
}
override fun onDialogClosed(positiveResult: Boolean) {
if (positiveResult) {
// TODO Save selected item in JSON
mAdapter.selectedItem
passkeysPrivilegedAppsViewModel.saveSelectedPrivilegedApp(mAdapter.selectedItem)
}
}

View File

@@ -0,0 +1,52 @@
package com.kunzisoft.keepass.settings.preferencedialogfragment.viewmodel
import android.app.Application
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.deletePrivilegedAppsFile
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.retrieveAllPrivilegedApps
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps
import com.kunzisoft.keepass.utils.AppUtil.getInstalledBrowsersWithSignatures
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeysPrivilegedAppsViewModel(application: Application): AndroidViewModel(application) {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
fun retrievePrivilegedAppsToSelect() {
viewModelScope.launch {
val privilegedApps = retrieveAllPrivilegedApps(getApplication())
val privilegedAppsToSelect = getInstalledBrowsersWithSignatures(getApplication())
.filter {
privilegedApps.none { privilegedApp ->
privilegedApp.packageName == it.packageName
&& privilegedApp.fingerprints.any {
fingerprint -> fingerprint in it.fingerprints
}
}
}
_uiState.value = UiState.OnPrivilegedAppsToSelectRetrieved(privilegedAppsToSelect)
}
}
fun saveSelectedPrivilegedApp(privilegedApp: AndroidPrivilegedApp?) {
viewModelScope.launch {
privilegedApp?.let {
saveCustomPrivilegedApps(getApplication(), listOf(privilegedApp))
} ?: deletePrivilegedAppsFile(getApplication())
}
}
sealed class UiState {
object Loading : UiState()
data class OnPrivilegedAppsToSelectRetrieved(val privilegedApps: List<AndroidPrivilegedApp>) : UiState()
}
}

View File

@@ -8,11 +8,11 @@ import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import com.kunzisoft.encrypt.Signature.getApplicationFingerprints
import com.kunzisoft.encrypt.Signature.getAllFingerprints
import com.kunzisoft.keepass.BuildConfig
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.education.Education
import com.kunzisoft.keepass.model.AndroidOrigin
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.settings.PreferencesUtil
import kotlinx.coroutines.CoroutineScope
@@ -103,9 +103,9 @@ object AppUtil {
}
@RequiresApi(Build.VERSION_CODES.P)
fun getInstalledBrowsersWithSignatures(context: Context): List<AndroidOrigin> {
fun getInstalledBrowsersWithSignatures(context: Context): List<AndroidPrivilegedApp> {
val packageManager = context.packageManager
val browserList = mutableListOf<AndroidOrigin>()
val browserList = mutableListOf<AndroidPrivilegedApp>()
// Create a generic web intent
val intent = Intent(Intent.ACTION_VIEW)
@@ -132,9 +132,9 @@ object AppUtil {
packageName,
PackageManager.GET_SIGNING_CERTIFICATES
)
val signatureFingerprints = packageInfo.signingInfo.getApplicationFingerprints()
val signatureFingerprints = packageInfo.signingInfo.getAllFingerprints()
signatureFingerprints?.let {
browserList.add(AndroidOrigin(packageName, signatureFingerprints))
browserList.add(AndroidPrivilegedApp(packageName, signatureFingerprints))
processedPackageNames.add(packageName)
}
} catch (e: Exception) {

View File

@@ -29,7 +29,7 @@
<TextView
android:id="@+id/pref_dialog_list_text"
android:layout_margin="8dp"
style="@style/KeepassDXStyle.Title.Entry"
style="@style/KeepassDXStyle.SubTitle.Entry"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@@ -1,3 +1,22 @@
/*
* Copyright 2025 Cali-95 modified by 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.encrypt
import android.content.pm.SigningInfo
@@ -272,32 +291,29 @@ object Signature {
/**
* 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 getAllFingerprints(signingInfo: SigningInfo?): List<String>? {
fun SigningInfo.getAllFingerprints(): Set<String>? {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
throw AndroidException("API level ${Build.VERSION.SDK_INT} not supported")
val signatures = mutableSetOf<String>()
if (signingInfo != null) {
// Includes past and current keys if rotation occurred. This is generally preferred.
signingInfo.signingCertificateHistory?.forEach { signature ->
// Includes past and current keys if rotation occurred. This is generally preferred.
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 (hasMultipleSigners()) {
apkContentsSigners?.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) }
}
} else { // Fallback for single signer if history was somehow null/empty
signingCertificateHistory?.firstOrNull()?.let {
signatureToSha256Fingerprint(it)?.let { fp -> signatures.add(fp) }
}
}
return if (signatures.isEmpty()) null else signatures.toList()
return if (signatures.isEmpty()) null else signatures
} catch (e: Exception) {
Log.e(Signature::class.java.simpleName, "Error getting signatures", e)
return null
@@ -305,17 +321,24 @@ object Signature {
}
/**
* Combines a list of signature into a single string for database storage.
* Combines a list of signatures into a single string for database storage.
*
* @return A single string with fingerprints joined by a ##SIG## delimiter,
* or null if the input list is null or empty.
*/
fun SigningInfo.getApplicationFingerprints(): String? {
val fingerprints = getAllFingerprints(this)
val fingerprints = getAllFingerprints()
if (fingerprints.isNullOrEmpty()) {
return null
}
return fingerprints.joinToString(SIGNATURE_DELIMITER)
return fingerprints.singleLineFingerprints()
}
/**
* Combines a set of signatures into a single string for database storage.
*/
fun Set<String>.singleLineFingerprints(): String? {
return this.joinToString(SIGNATURE_DELIMITER)
}
/**

View File

@@ -25,6 +25,10 @@ import com.kunzisoft.encrypt.Signature.fingerprintToUrlSafeBase64
import com.kunzisoft.keepass.model.WebOrigin.Companion.RELYING_PARTY_DEFAULT_PROTOCOL
import kotlinx.parcelize.Parcelize
/**
* Represents an Android app origin by a list of [AndroidOrigin] and a list of [WebOrigin].
* If at least one [AndroidOrigin] is verified, the [verified] flag is set to true.
*/
@Parcelize
data class AppOrigin(
val verified: Boolean,
@@ -96,6 +100,10 @@ data class AppOrigin(
}
}
/**
* Represents an Android app origin, the [packageName] is the applicationId of the app
* and the [fingerprint] is the
*/
@Parcelize
data class AndroidOrigin(
val packageName: String,