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") Log.d(TAG, "Launch passkey selection")
retrievePasskeyUsageRequestParameters( retrievePasskeyUsageRequestParameters(
intent = intent, intent = intent,
assetManager = assets context = applicationContext
) { usageParameters -> ) { usageParameters ->
// Save the requested parameters // Save the requested parameters
mUsageParameters = usageParameters mUsageParameters = usageParameters
@@ -304,7 +304,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
Log.d(TAG, "Launch passkey registration") Log.d(TAG, "Launch passkey registration")
retrievePasskeyCreationRequestParameters( retrievePasskeyCreationRequestParameters(
intent = intent, intent = intent,
assetManager = assets, context = applicationContext,
passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters -> passkeyCreated = { passkey, appInfoToStore, publicKeyCredentialParameters ->
// Save the requested parameters // Save the requested parameters
mPasskey = passkey 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 package com.kunzisoft.keepass.credentialprovider.passkey.util
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.AssetManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.ParcelUuid import android.os.ParcelUuid
@@ -42,7 +42,6 @@ import androidx.credentials.provider.ProviderGetCredentialRequest
import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode import com.kunzisoft.encrypt.Base64Helper.Companion.b64Encode
import com.kunzisoft.encrypt.Signature import com.kunzisoft.encrypt.Signature
import com.kunzisoft.encrypt.Signature.getApplicationFingerprints 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.AuthenticatorAssertionResponse
import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAttestationResponse import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAttestationResponse
import com.kunzisoft.keepass.credentialprovider.passkey.data.Cbor 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.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters 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.AndroidOrigin
import com.kunzisoft.keepass.model.AppOrigin import com.kunzisoft.keepass.model.AppOrigin
import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.EntryInfo
@@ -328,61 +328,20 @@ object PasskeyHelper {
return request.credentialOptions[0] as GetPublicKeyCredentialOption 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, * 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 [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 * call [onOriginNotRetrieved] if the origin is not retrieved from the system, return a new Android Origin
*/ */
suspend fun getOrigin( suspend fun getOrigin(
providedClientDataHash: ByteArray?, providedClientDataHash: ByteArray?,
callingAppInfo: CallingAppInfo?, callingAppInfo: CallingAppInfo?,
assets: AssetManager, context: Context,
onOriginRetrieved: (appOrigin: AppOrigin, clientDataHash: ByteArray) -> Unit, onOriginRetrieved: (appOrigin: AppOrigin, clientDataHash: ByteArray) -> Unit,
onOriginNotRetrieved: (appOrigin: AppOrigin, androidOriginString: String) -> Unit onOriginNotRetrieved: (appOrigin: AppOrigin, androidOriginString: String) -> Unit
) { ) {
@@ -392,7 +351,7 @@ object PasskeyHelper {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// For trusted browsers like Chrome and Firefox // For trusted browsers like Chrome and Firefox
val callOrigin = getOriginFromPrivilegedAllowLists(callingAppInfo, assets) val callOrigin = getOriginFromPrivilegedAllowLists(callingAppInfo, context)
// Build the default Android origin // Build the default Android origin
val androidOrigin = AndroidOrigin( val androidOrigin = AndroidOrigin(
@@ -436,12 +395,12 @@ object PasskeyHelper {
/** /**
* Utility method to create a passkey and the associated creation request parameters * Utility method to create a passkey and the associated creation request parameters
* [intent] allows to retrieve the request * [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 * [passkeyCreated] is called asynchronously when the passkey has been created
*/ */
suspend fun retrievePasskeyCreationRequestParameters( suspend fun retrievePasskeyCreationRequestParameters(
intent: Intent, intent: Intent,
assetManager: AssetManager, context: Context,
passkeyCreated: (Passkey, AppOrigin?, PublicKeyCredentialCreationParameters) -> Unit passkeyCreated: (Passkey, AppOrigin?, PublicKeyCredentialCreationParameters) -> Unit
) { ) {
val createCredentialRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) val createCredentialRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)
@@ -476,7 +435,7 @@ object PasskeyHelper {
getOrigin( getOrigin(
providedClientDataHash = clientDataHash, providedClientDataHash = clientDataHash,
callingAppInfo = callingAppInfo, callingAppInfo = callingAppInfo,
assets = assetManager, context = context,
onOriginRetrieved = { appInfoToStore, clientDataHash -> onOriginRetrieved = { appInfoToStore, clientDataHash ->
passkeyCreated.invoke( passkeyCreated.invoke(
passkey, passkey,
@@ -546,12 +505,12 @@ object PasskeyHelper {
/** /**
* Utility method to use a passkey and create the associated usage request parameters * Utility method to use a passkey and create the associated usage request parameters
* [intent] allows to retrieve the request * [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 * [result] is called asynchronously after the creation of PublicKeyCredentialUsageParameters, the origin associated with it may or may not be verified
*/ */
suspend fun retrievePasskeyUsageRequestParameters( suspend fun retrievePasskeyUsageRequestParameters(
intent: Intent, intent: Intent,
assetManager: AssetManager, context: Context,
result: (PublicKeyCredentialUsageParameters) -> Unit result: (PublicKeyCredentialUsageParameters) -> Unit
) { ) {
val getCredentialRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) val getCredentialRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)
@@ -566,7 +525,7 @@ object PasskeyHelper {
getOrigin( getOrigin(
providedClientDataHash = clientDataHash, providedClientDataHash = clientDataHash,
callingAppInfo = callingAppInfo, callingAppInfo = callingAppInfo,
assets = assetManager, context = context,
onOriginRetrieved = { appOrigin, clientDataHash -> onOriginRetrieved = { appOrigin, clientDataHash ->
result.invoke( result.invoke(
PublicKeyCredentialUsageParameters( PublicKeyCredentialUsageParameters(
@@ -642,7 +601,4 @@ object PasskeyHelper {
} }
private const val BACKUP_ELIGIBILITY = true 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.os.Bundle
import android.view.View import android.view.View
import androidx.annotation.RequiresApi 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.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.kunzisoft.keepass.R 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.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) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeysPrivilegedAppsPreferenceDialogFragmentCompat class PasskeysPrivilegedAppsPreferenceDialogFragmentCompat
: InputPreferenceDialogFragmentCompat() { : 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) { override fun onBindDialogView(view: View) {
super.onBindDialogView(view) super.onBindDialogView(view)
view.findViewById<RecyclerView>(R.id.pref_dialog_list).apply { view.findViewById<RecyclerView>(R.id.pref_dialog_list).apply {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
adapter = mAdapter.apply { adapter = mAdapter
setItems(mListBrowsers)
}
} }
} }
override fun onDialogClosed(positiveResult: Boolean) { override fun onDialogClosed(positiveResult: Boolean) {
if (positiveResult) { if (positiveResult) {
// TODO Save selected item in JSON passkeysPrivilegedAppsViewModel.saveSelectedPrivilegedApp(mAdapter.selectedItem)
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 android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.net.toUri 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.BuildConfig
import com.kunzisoft.keepass.R import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.education.Education import com.kunzisoft.keepass.education.Education
import com.kunzisoft.keepass.model.AndroidOrigin
import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.settings.PreferencesUtil
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -103,9 +103,9 @@ object AppUtil {
} }
@RequiresApi(Build.VERSION_CODES.P) @RequiresApi(Build.VERSION_CODES.P)
fun getInstalledBrowsersWithSignatures(context: Context): List<AndroidOrigin> { fun getInstalledBrowsersWithSignatures(context: Context): List<AndroidPrivilegedApp> {
val packageManager = context.packageManager val packageManager = context.packageManager
val browserList = mutableListOf<AndroidOrigin>() val browserList = mutableListOf<AndroidPrivilegedApp>()
// Create a generic web intent // Create a generic web intent
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
@@ -132,9 +132,9 @@ object AppUtil {
packageName, packageName,
PackageManager.GET_SIGNING_CERTIFICATES PackageManager.GET_SIGNING_CERTIFICATES
) )
val signatureFingerprints = packageInfo.signingInfo.getApplicationFingerprints() val signatureFingerprints = packageInfo.signingInfo.getAllFingerprints()
signatureFingerprints?.let { signatureFingerprints?.let {
browserList.add(AndroidOrigin(packageName, signatureFingerprints)) browserList.add(AndroidPrivilegedApp(packageName, signatureFingerprints))
processedPackageNames.add(packageName) processedPackageNames.add(packageName)
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -29,7 +29,7 @@
<TextView <TextView
android:id="@+id/pref_dialog_list_text" android:id="@+id/pref_dialog_list_text"
android:layout_margin="8dp" android:layout_margin="8dp"
style="@style/KeepassDXStyle.Title.Entry" style="@style/KeepassDXStyle.SubTitle.Entry"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
</LinearLayout> </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 package com.kunzisoft.encrypt
import android.content.pm.SigningInfo import android.content.pm.SigningInfo
@@ -272,32 +291,29 @@ object Signature {
/** /**
* Retrieves all relevant SHA-256 signature fingerprints for a given package. * 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. * @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 { try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
throw AndroidException("API level ${Build.VERSION.SDK_INT} not supported") throw AndroidException("API level ${Build.VERSION.SDK_INT} not supported")
val signatures = mutableSetOf<String>() val signatures = mutableSetOf<String>()
if (signingInfo != null) { // Includes past and current keys if rotation occurred. This is generally preferred.
// Includes past and current keys if rotation occurred. This is generally preferred. signingCertificateHistory?.forEach { signature ->
signingInfo.signingCertificateHistory?.forEach { signature -> signatureToSha256Fingerprint(signature)?.let { signatures.add(it) }
}
// If only one signer and history is empty (e.g. new app), this might be needed.
// Or if multiple signers are explicitly used for the APK content.
if (hasMultipleSigners()) {
apkContentsSigners?.forEach { signature ->
signatureToSha256Fingerprint(signature)?.let { signatures.add(it) } signatureToSha256Fingerprint(signature)?.let { signatures.add(it) }
} }
// If only one signer and history is empty (e.g. new app), this might be needed. } else { // Fallback for single signer if history was somehow null/empty
// Or if multiple signers are explicitly used for the APK content. signingCertificateHistory?.firstOrNull()?.let {
if (signingInfo.hasMultipleSigners()) { signatureToSha256Fingerprint(it)?.let { fp -> signatures.add(fp) }
signingInfo.apkContentsSigners?.forEach { signature ->
signatureToSha256Fingerprint(signature)?.let { signatures.add(it) }
}
} else { // Fallback for single signer if history was somehow null/empty
signingInfo.signingCertificateHistory?.firstOrNull()?.let {
signatureToSha256Fingerprint(it)?.let { fp -> signatures.add(fp) }
}
} }
} }
return if (signatures.isEmpty()) null else signatures.toList() return if (signatures.isEmpty()) null else signatures
} catch (e: Exception) { } catch (e: Exception) {
Log.e(Signature::class.java.simpleName, "Error getting signatures", e) Log.e(Signature::class.java.simpleName, "Error getting signatures", e)
return null 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, * @return A single string with fingerprints joined by a ##SIG## delimiter,
* or null if the input list is null or empty. * or null if the input list is null or empty.
*/ */
fun SigningInfo.getApplicationFingerprints(): String? { fun SigningInfo.getApplicationFingerprints(): String? {
val fingerprints = getAllFingerprints(this) val fingerprints = getAllFingerprints()
if (fingerprints.isNullOrEmpty()) { if (fingerprints.isNullOrEmpty()) {
return null 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 com.kunzisoft.keepass.model.WebOrigin.Companion.RELYING_PARTY_DEFAULT_PROTOCOL
import kotlinx.parcelize.Parcelize 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 @Parcelize
data class AppOrigin( data class AppOrigin(
val verified: Boolean, 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 @Parcelize
data class AndroidOrigin( data class AndroidOrigin(
val packageName: String, val packageName: String,