CallingAppInfo.kt

/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.credentials.provider

import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
import androidx.credentials.provider.utils.PrivilegedApp
import androidx.credentials.provider.utils.RequestValidationUtil
import java.security.MessageDigest
import org.json.JSONException
import org.json.JSONObject

/**
 * Information pertaining to the calling application.
 *
 * @constructor constructs an instance of [CallingAppInfo]
 *
 * @param packageName the calling package name of the calling app
 * @param signingInfo the signingInfo associated with the calling app
 * @param origin the origin of the calling app. This is only set when a
 * privileged app like a browser, calls on behalf of another application.
 *
 * @throws NullPointerException If [packageName] or [signingInfo] is null
 * @throws IllegalArgumentException If [packageName] is empty
 *
 * Note : Credential providers are not expected to utilize the constructor in this class for any
 * production flow. This constructor must only be used for testing purposes.
 */
class CallingAppInfo @JvmOverloads constructor(
    val packageName: String,
    val signingInfo: SigningInfo,
    @get:RestrictTo(RestrictTo.Scope.LIBRARY)
    val origin: String? = null
) {
    internal companion object {
        private const val TAG = "CallingAppInfo"
    }

    /**
     * Returns the origin of the calling app. This is only non-null if a
     * privileged app like a browser calls Credential Manager APIs on
     * behalf of another application.
     *
     * Additionally, in order to get the origin, the credential provider must
     * provide an allowlist of privileged browsers/apps that it trusts.
     * This allowlist must be in the form of a valid, non-empty JSON. The
     * origin will only be returned if the [packageName] and the SHA256 hash of the newest
     * signature obtained from the [signingInfo], is present in the [privilegedAllowlist].
     *
     * Packages that are signed with multiple signers will only receive the origin if all of the
     * signatures are present in the [privilegedAllowlist].
     *
     * The format of this [privilegedAllowlist] JSON must adhere to the following sample.
     *
     * ```
     * {"apps": [
     *    {
     *       "type": "android",
     *       "info": {
     *          "package_name": "com.example.myapp",
     *          "signatures" : [
     *          {"build": "release",
     *              "cert_fingerprint_sha256": "59:0D:2D:7B:33:6A:BD:FB:54:CD:3D:8B:36:8C:5C:3A:
     *              7D:22:67:5A:9A:85:9A:6A:65:47:FD:4C:8A:7C:30:32"
     *          },
     *          {"build": "userdebug",
     *          "cert_fingerprint_sha256": "59:0D:2D:7B:33:6A:BD:FB:54:CD:3D:8B:36:8C:5C:3A:7D:
     *          22:67:5A:9A:85:9A:6A:65:47:FD:4C:8A:7C:30:32"
     *          }]
     *       }
     *     }
     * ]}
     * ```
     *
     * All keys in the JSON must be exactly as stated in the sample above. Note that if the build
     * for a given fingerprint is specified as 'userdebug', that fingerprint will
     * only be considered if the device is on a 'userdebug' build, as determined by [Build.TYPE].
     *
     * @throws IllegalArgumentException If [privilegedAllowlist] is empty, or an
     * invalid JSON, or does not follow the format detailed above
     * @throws IllegalStateException If the origin is non-null, but the [packageName] and
     * [signingInfo] do not have a match in the [privilegedAllowlist]
     */
    fun getOrigin(privilegedAllowlist: String): String? {
        if (!RequestValidationUtil.isValidJSON(privilegedAllowlist)) {
            throw IllegalArgumentException(
                "privilegedAllowlist must not be " +
                    "empty, and must be a valid JSON"
            )
        }
        if (origin == null) {
            // If origin is null, then this is not a privileged call
            return origin
        }
        try {
            if (isAppPrivileged(
                    PrivilegedApp.extractPrivilegedApps(
                        JSONObject(privilegedAllowlist)
                    )
                )
            ) {
                return origin
            }
        } catch (_: JSONException) {
            throw IllegalArgumentException("privilegedAllowlist must be formatted properly")
        }
        throw IllegalStateException("Origin is not being returned as the calling app did not" +
            "match the privileged allowlist")
    }

    /**
     * Returns true if the [origin] is populated, and false otherwise.
     *
     * Note that the [origin] is only populated if a privileged app like a browser calls
     * Credential Manager APIs on behalf of another application.
     */
    fun isOriginPopulated(): Boolean {
        return origin != null
    }

    private fun isAppPrivileged(
        candidateApps: List<PrivilegedApp>
    ): Boolean {
        for (app in candidateApps) {
            if (app.packageName == packageName) {
                return isAppPrivileged(app.fingerprints)
            }
        }
        return false
    }

    private fun isAppPrivileged(candidateFingerprints: Set<String>): Boolean {
        if (Build.VERSION.SDK_INT >= 28) {
            return SignatureVerifierApi28(signingInfo)
                .verifySignatureFingerprints(candidateFingerprints)
        }
        // TODO("Extend to <= 28 if needed")
        return false
    }

    init {
        require(packageName.isNotEmpty()) { "packageName must not be empty" }
    }

    @RequiresApi(28)
    private class SignatureVerifierApi28(private val signingInfo: SigningInfo) {
        private fun getSignatureFingerprints(): Set<String> {
            val fingerprints = mutableSetOf<String>()
            if (signingInfo.hasMultipleSigners() && signingInfo.apkContentsSigners != null) {
                fingerprints.addAll(convertToFingerprints(signingInfo.apkContentsSigners))
            } else if (signingInfo.signingCertificateHistory != null) {
                fingerprints.addAll(convertToFingerprints(
                    arrayOf(signingInfo.signingCertificateHistory[0])))
            }
            return fingerprints
        }

        private fun convertToFingerprints(signatures: Array<Signature>): Set<String> {
            val fingerprints = mutableSetOf<String>()
            for (signature in signatures) {
                val md = MessageDigest.getInstance("SHA-256")
                val digest = md.digest(signature.toByteArray())
                fingerprints.add(digest.joinToString(":") { "%02X".format(it) })
            }
            return fingerprints
        }

        fun verifySignatureFingerprints(candidateSigFingerprints: Set<String>): Boolean {
            val appSigFingerprints = getSignatureFingerprints()
            return if (signingInfo.hasMultipleSigners()) {
                candidateSigFingerprints.containsAll(appSigFingerprints)
            } else {
                candidateSigFingerprints.intersect(appSigFingerprints).isNotEmpty()
            }
        }
    }
}