/* * 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 private val platformSignature: String private val callerChecked = mutableMapOf>() 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() 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 { val certificateWhitelist = LinkedHashMap() 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() 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 ) 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 ) } private const val TAG = "PackageValidator" private const val ANDROID_PLATFORM = "android" private val WHITESPACE_REGEX = "\\s|\\n".toRegex()