From 988b18b51530b1477784b8a305514278f2bda0cf Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 3 Sep 2025 14:25:53 +0200 Subject: [PATCH] feat: JSON parser to manage Privileged apps --- .../activity/PasskeyLauncherActivity.kt | 4 +- .../passkey/data/AndroidPrivilegedApp.kt | 145 ++++++++++++ .../passkey/util/PasskeyHelper.kt | 74 ++----- .../passkey/util/PrivilegedAllowLists.kt | 206 ++++++++++++++++++ ...legedAppsPreferenceDialogFragmentCompat.kt | 38 +++- .../PasskeysPrivilegedAppsViewModel.kt | 52 +++++ .../com/kunzisoft/keepass/utils/AppUtil.kt | 12 +- .../main/res/layout/pref_dialog_list_item.xml | 2 +- .../java/com/kunzisoft/encrypt/Signature.kt | 61 ++++-- .../com/kunzisoft/keepass/model/AppOrigin.kt | 8 + 10 files changed, 504 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AndroidPrivilegedApp.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PrivilegedAllowLists.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/viewmodel/PasskeysPrivilegedAppsViewModel.kt diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index 7239038dc..34ddbae3a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -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 diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AndroidPrivilegedApp.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AndroidPrivilegedApp.kt new file mode 100644 index 000000000..867efb58c --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AndroidPrivilegedApp.kt @@ -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 . + * + */ +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 +): 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 { + val apps = mutableListOf() + 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() + 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): 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 + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt index 5b5ccb657..5e9f66aae 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -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" } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PrivilegedAllowLists.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PrivilegedAllowLists.kt new file mode 100644 index 000000000..6640bb454 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PrivilegedAllowLists.kt @@ -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 . + * + */ +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 { + 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 { + return try { + val predefinedApps = mutableListOf() + 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 { + 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 { + 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): 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.kt index f331580a0..468318b68 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/PasskeysPrivilegedAppsPreferenceDialogFragmentCompat.kt @@ -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() - private var mListBrowsers: List = mutableListOf() + + private var mAdapter = ListSelectionItemAdapter() + 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(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) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/viewmodel/PasskeysPrivilegedAppsViewModel.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/viewmodel/PasskeysPrivilegedAppsViewModel.kt new file mode 100644 index 000000000..37ebee701 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preferencedialogfragment/viewmodel/PasskeysPrivilegedAppsViewModel.kt @@ -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.Loading) + val uiState: StateFlow = _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) : UiState() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/AppUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/AppUtil.kt index 138dacfba..107fbc1c9 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/AppUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/AppUtil.kt @@ -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 { + fun getInstalledBrowsersWithSignatures(context: Context): List { val packageManager = context.packageManager - val browserList = mutableListOf() + val browserList = mutableListOf() // 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) { diff --git a/app/src/main/res/layout/pref_dialog_list_item.xml b/app/src/main/res/layout/pref_dialog_list_item.xml index 9c985da2c..fd4d2768b 100644 --- a/app/src/main/res/layout/pref_dialog_list_item.xml +++ b/app/src/main/res/layout/pref_dialog_list_item.xml @@ -29,7 +29,7 @@ \ No newline at end of file diff --git a/crypto/src/main/java/com/kunzisoft/encrypt/Signature.kt b/crypto/src/main/java/com/kunzisoft/encrypt/Signature.kt index 73432335e..0f599818e 100644 --- a/crypto/src/main/java/com/kunzisoft/encrypt/Signature.kt +++ b/crypto/src/main/java/com/kunzisoft/encrypt/Signature.kt @@ -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 . + * + */ 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? { + fun SigningInfo.getAllFingerprints(): Set? { try { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) throw AndroidException("API level ${Build.VERSION.SDK_INT} not supported") val signatures = mutableSetOf() - 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.singleLineFingerprints(): String? { + return this.joinToString(SIGNATURE_DELIMITER) } /** diff --git a/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt b/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt index 31208a90d..b9878b2f8 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/AppOrigin.kt @@ -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,