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