PlayerAndroid/app/src/main/java/code/name/monkey/retromusic/util/PackageValidator.kt

352 lines
14 KiB
Kotlin

/*
* Copyright (c) 2019 Hemanth Savarala.
*
* Licensed under the GNU General Public License v3
*
* This 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.
*
* This software 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.
*/
package code.name.monkey.retromusic.util
import android.Manifest.permission.BIND_NOTIFICATION_LISTENER_SERVICE
import android.Manifest.permission.MEDIA_CONTENT_CONTROL
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
import android.content.pm.PackageManager
import android.content.res.XmlResourceParser
import android.os.Process
import android.support.v4.media.session.MediaSessionCompat
import android.util.Base64
import android.util.Log
import androidx.annotation.XmlRes
import androidx.media.MediaBrowserServiceCompat
import code.name.monkey.retromusic.BuildConfig
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.*
import kotlin.collections.LinkedHashMap
/**
* Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
*
* The list of allowed signing certificates and their corresponding package names is defined in
* res/xml/allowed_media_browser_callers.xml.
*
* If you want to add a new caller to allowed_media_browser_callers.xml and you don't know
* its signature, this class will print to logcat (INFO level) a message with the proper
* xml tags to add to allow the caller.
*
* For more information, see res/xml/allowed_media_browser_callers.xml.
*/
class PackageValidator(
context: Context,
@XmlRes xmlResId: Int
) {
private val context: Context
private val packageManager: PackageManager
private val certificateWhitelist: Map<String, KnownCallerInfo>
private val platformSignature: String
private val callerChecked = mutableMapOf<String, Pair<Int, Boolean>>()
init {
val parser = context.resources.getXml(xmlResId)
this.context = context.applicationContext
this.packageManager = this.context.packageManager
certificateWhitelist = buildCertificateWhitelist(parser)
platformSignature = getSystemSignature()
}
/**
* Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known.
* See [MusicService.onGetRoot] for where this is utilized.
*
* @param callingPackage The package name of the caller.
* @param callingUid The user id of the caller.
* @return `true` if the caller is known, `false` otherwise.
*/
fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean {
// If the caller has already been checked, return the previous result here.
val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false)
if (checkedUid == callingUid) {
return checkResult
}
/**
* Because some of these checks can be slow, we save the results in [callerChecked] after
* this code is run.
*
* In particular, there's little reason to recompute the calling package's certificate
* signature (SHA-256) each call.
*
* This is safe to do as we know the UID matches the package's UID (from the check above),
* and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
* be constant until a reboot. (After a reboot then a previously assigned UID could be
* reassigned.)
*/
// Build the caller info for the rest of the checks here.
val callerPackageInfo = buildCallerInfo(callingPackage)
?: throw IllegalStateException("Caller wasn't found in the system?")
// Verify that things aren't ... broken. (This test should always pass.)
if (callerPackageInfo.uid != callingUid) {
throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?")
}
val callerSignature = callerPackageInfo.signature
val isPackageInWhitelist = certificateWhitelist[callingPackage]?.signatures?.first {
it.signature == callerSignature
} != null
val isCallerKnown = when {
// If it's our own app making the call, allow it.
callingUid == Process.myUid() -> true
// If it's one of the apps on the whitelist, allow it.
isPackageInWhitelist -> true
// If the system is making the call, allow it.
callingUid == Process.SYSTEM_UID -> true
// If the app was signed by the same certificate as the platform itself, also allow it.
callerSignature == platformSignature -> true
/**
* [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
* while it isn't required to allow these apps to connect to a
* [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
* such as Android TV and the Google Assistant.
*/
callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
/**
* This last permission can be specifically granted to apps, and, in addition to
* allowing them to retrieve notifications, it also allows them to connect to an
* active [MediaSessionCompat].
* As with the above, it's not required to allow apps holding this permission to
* connect to your [MediaBrowserServiceCompat], but it does allow easy comparability
* with apps such as Wear OS.
*/
callerPackageInfo.permissions.contains(BIND_NOTIFICATION_LISTENER_SERVICE) -> true
// If none of the pervious checks succeeded, then the caller is unrecognized.
else -> false
}
if (!isCallerKnown) {
logUnknownCaller(callerPackageInfo)
}
// Save our work for next time.
callerChecked[callingPackage] = Pair(callingUid, isCallerKnown)
return isCallerKnown
}
/**
* Logs an info level message with details of how to add a caller to the allowed callers list
* when the app is debuggable.
*/
private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) {
if (BuildConfig.DEBUG && callerPackageInfo.signature != null) {
Log.i(TAG, "PackageValidator call" + callerPackageInfo.name + callerPackageInfo.packageName + callerPackageInfo.signature)
}
}
/**
* Builds a [CallerPackageInfo] for a given package that can be used for all the
* various checks that are performed before allowing an app to connect to a
* [MediaBrowserServiceCompat].
*/
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
val packageInfo = getPackageInfo(callingPackage) ?: return null
val appName = packageInfo.applicationInfo.loadLabel(packageManager).toString()
val uid = packageInfo.applicationInfo.uid
val signature = getSignature(packageInfo)
val requestedPermissions = packageInfo.requestedPermissions
val permissionFlags = packageInfo.requestedPermissionsFlags
val activePermissions = mutableSetOf<String>()
requestedPermissions?.forEachIndexed { index, permission ->
if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) {
activePermissions += permission
}
}
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
}
/**
* Looks up the [PackageInfo] for a package name.
* This requests both the signatures (for checking if an app is on the whitelist) and
* the app's permissions, which allow for more flexibility in the whitelist.
*
* @return [PackageInfo] for the package name or null if it's not found.
*/
@Suppress("Deprecation")
@SuppressLint("PackageManagerGetSignatures")
private fun getPackageInfo(callingPackage: String): PackageInfo? =
packageManager.getPackageInfo(callingPackage,
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS)
/**
* Gets the signature of a given package's [PackageInfo].
*
* The "signature" is a SHA-256 hash of the public key of the signing certificate used by
* the app.
*
* If the app is not found, or if the app does not have exactly one signature, this method
* returns `null` as the signature.
*/
private fun getSignature(packageInfo: PackageInfo): String? {
// Security best practices dictate that an app should be signed with exactly one (1)
// signature. Because of this, if there are multiple signatures, reject it.
return if (packageInfo.signatures == null || packageInfo.signatures.size != 1) {
null
} else {
val certificate = packageInfo.signatures[0].toByteArray()
getSignatureSha256(certificate)
}
}
private fun buildCertificateWhitelist(parser: XmlResourceParser): Map<String, KnownCallerInfo> {
val certificateWhitelist = LinkedHashMap<String, KnownCallerInfo>()
try {
var eventType = parser.next()
while (eventType != XmlResourceParser.END_DOCUMENT) {
if (eventType == XmlResourceParser.START_TAG) {
val callerInfo = when (parser.name) {
"signing_certificate" -> parseV1Tag(parser)
"signature" -> parseV2Tag(parser)
else -> null
}
callerInfo?.let { info ->
val packageName = info.packageName
val existingCallerInfo = certificateWhitelist[packageName]
if (existingCallerInfo != null) {
existingCallerInfo.signatures += callerInfo.signatures
} else {
certificateWhitelist[packageName] = callerInfo
}
}
}
eventType = parser.next()
}
} catch (xmlException: XmlPullParserException) {
Log.e(TAG, "Could not read allowed callers from XML.", xmlException)
} catch (ioException: IOException) {
Log.e(TAG, "Could not read allowed callers from XML.", ioException)
}
return certificateWhitelist
}
/**
* Parses a v1 format tag. See allowed_media_browser_callers.xml for more details.
*/
private fun parseV1Tag(parser: XmlResourceParser): KnownCallerInfo {
val name = parser.getAttributeValue(null, "name")
val packageName = parser.getAttributeValue(null, "package")
val isRelease = parser.getAttributeBooleanValue(null, "release", false)
val certificate = parser.nextText().replace(WHITESPACE_REGEX, "")
val signature = getSignatureSha256(certificate)
val callerSignature = KnownSignature(signature, isRelease)
return KnownCallerInfo(name, packageName, mutableSetOf(callerSignature))
}
/**
* Parses a v2 format tag. See allowed_media_browser_callers.xml for more details.
*/
private fun parseV2Tag(parser: XmlResourceParser): KnownCallerInfo {
val name = parser.getAttributeValue(null, "name")
val packageName = parser.getAttributeValue(null, "package")
val callerSignatures = mutableSetOf<KnownSignature>()
var eventType = parser.next()
while (eventType != XmlResourceParser.END_TAG) {
val isRelease = parser.getAttributeBooleanValue(null, "release", false)
val signature = parser.nextText().replace(WHITESPACE_REGEX, "")
.lowercase(Locale.getDefault())
callerSignatures += KnownSignature(signature, isRelease)
eventType = parser.next()
}
return KnownCallerInfo(name, packageName, callerSignatures)
}
/**
* Finds the Android platform signing key signature. This key is never null.
*/
private fun getSystemSignature(): String =
getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
getSignature(platformInfo)
} ?: throw IllegalStateException("Platform signature not found")
/**
* Creates a SHA-256 signature given a Base64 encoded certificate.
*/
private fun getSignatureSha256(certificate: String): String {
return getSignatureSha256(Base64.decode(certificate, Base64.DEFAULT))
}
/**
* Creates a SHA-256 signature given a certificate byte array.
*/
private fun getSignatureSha256(certificate: ByteArray): String {
val md: MessageDigest
try {
md = MessageDigest.getInstance("SHA256")
} catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
Log.e(TAG, "No such algorithm: $noSuchAlgorithmException")
throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
}
md.update(certificate)
// This code takes the byte array generated by `md.digest()` and joins each of the bytes
// to a string, applying the string format `%02x` on each digit before it's appended, with
// a colon (':') between each of the items.
// For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
return md.digest().joinToString(":") { String.format("%02x", it) }
}
private data class KnownCallerInfo(
val name: String,
val packageName: String,
val signatures: MutableSet<KnownSignature>
)
private data class KnownSignature(
val signature: String,
val release: Boolean
)
/**
* Convenience class to hold all of the information about an app that's being checked
* to see if it's a known caller.
*/
private data class CallerPackageInfo(
val name: String,
val packageName: String,
val uid: Int,
val signature: String?,
val permissions: Set<String>
)
}
private const val TAG = "PackageValidator"
private const val ANDROID_PLATFORM = "android"
private val WHITESPACE_REGEX = "\\s|\\n".toRegex()