chromium/components/externalauth/android/java/src/org/chromium/components/externalauth/ExternalAuthUtils.java

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.externalauth;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Binder;
import android.text.TextUtils;

import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.StrictModeContext;
import org.chromium.base.TraceEvent;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.embedder_support.util.Origin;
import org.chromium.gms.ChromiumPlayServicesAvailability;

/**
 * Utility class for external authentication tools.
 *
 * This class is safe to use on any thread.
 */
public class ExternalAuthUtils {
    public static final int FLAG_SHOULD_BE_GOOGLE_SIGNED = 1 << 0;
    public static final int FLAG_SHOULD_BE_SYSTEM = 1 << 1;
    private static final String TAG = "ExternalAuthUtils";
    private static ExternalAuthUtils sInstance = new ExternalAuthUtils();

    private final ExternalAuthGoogleDelegate mGoogleDelegate;

    public ExternalAuthUtils() {
        mGoogleDelegate = new ExternalAuthGoogleDelegateImpl();
    }

    /** @return The singleton instance of ExternalAuthUtils. */
    public static ExternalAuthUtils getInstance() {
        return sInstance;
    }

    /**
     * Gets the calling package names for the current transaction.
     * @return The calling package names.
     */
    private static String[] getCallingPackages() {
        int callingUid = Binder.getCallingUid();
        PackageManager pm = ContextUtils.getApplicationContext().getPackageManager();
        return pm.getPackagesForUid(callingUid);
    }

    /**
     * Returns whether the caller application is a part of the system build.
     *
     * @param pm Package manager to use for getting package related info.
     * @param packageName The package name to inquire about.
     */
    @VisibleForTesting
    // TODO(crbug.com/40479664): Fix this properly.
    @SuppressLint("WrongConstant")
    public boolean isSystemBuild(PackageManager pm, String packageName) {
        try {
            ApplicationInfo info = pm.getApplicationInfo(packageName, ApplicationInfo.FLAG_SYSTEM);
            if ((info.flags & ApplicationInfo.FLAG_SYSTEM) == 0) throw new SecurityException();
        } catch (NameNotFoundException e) {
            Log.e(TAG, "Package with name " + packageName + " not found");
            return false;
        } catch (SecurityException e) {
            Log.e(TAG, "Caller with package name " + packageName + " is not in the system build");
            return false;
        }

        return true;
    }

    /**
     * Returns whether the current build of Chrome is a Google-signed package.
     * @return whether the currently running application is signed with Google keys.
     */
    public boolean isChromeGoogleSigned() {
        String packageName = ContextUtils.getApplicationContext().getPackageName();
        return isGoogleSigned(packageName);
    }

    /**
     * Returns whether the call is originating from a Google-signed package.
     * @param packageName The package name to inquire about.
     */
    public boolean isGoogleSigned(String packageName) {
        return mGoogleDelegate.isGoogleSigned(packageName);
    }

    /**
     * Returns whether the package can bypass TWA verification.
     * @param packageName The package name to inquire about.
     * @param origin The origin of the TWA.
     */
    public boolean isAllowlistedForTwaVerification(String packageName, Origin origin) {
        return false;
    }

    /**
     * Returns whether the callers of the current transaction contains a package that matches
     * the give authentication requirements.
     * @param context The context to use for getting package information.
     * @param authRequirements The requirements to be exercised on the caller.
     * @param packageToMatch The package name to compare with the caller.
     * @return Whether the caller meets the authentication requirements.
     */
    private boolean isCallerValid(int authRequirements, String packageToMatch) {
        boolean shouldBeGoogleSigned = (authRequirements & FLAG_SHOULD_BE_GOOGLE_SIGNED) != 0;
        boolean shouldBeSystem = (authRequirements & FLAG_SHOULD_BE_SYSTEM) != 0;

        String[] callingPackages = getCallingPackages();
        PackageManager pm = ContextUtils.getApplicationContext().getPackageManager();
        boolean matchFound = false;

        for (String packageName : callingPackages) {
            if (!TextUtils.isEmpty(packageToMatch) && !packageName.equals(packageToMatch)) continue;
            matchFound = true;
            if ((shouldBeGoogleSigned && !isGoogleSigned(packageName))
                    || (shouldBeSystem && !isSystemBuild(pm, packageName))) {
                return false;
            }
        }
        return matchFound;
    }

    /**
     * Returns whether the callers of the current transaction contains a package that matches
     * the give authentication requirements.
     * @param context The context to use for getting package information.
     * @param authRequirements The requirements to be exercised on the caller.
     * @param packageToMatch The package name to compare with the caller. Should be non-empty.
     * @return Whether the caller meets the authentication requirements.
     */
    public boolean isCallerValidForPackage(int authRequirements, String packageToMatch) {
        assert !TextUtils.isEmpty(packageToMatch);

        return isCallerValid(authRequirements, packageToMatch);
    }

    /**
     * Returns whether the callers of the current transaction matches the given authentication
     * requirements.
     * @param context The context to use for getting package information.
     * @param authRequirements The requirements to be exercised on the caller.
     * @return Whether the caller meets the authentication requirements.
     */
    public boolean isCallerValid(int authRequirements) {
        return isCallerValid(authRequirements, "");
    }

    /**
     * @return Whether the current device lacks proper Google Play Services. This will return true
     *         if the service is not authentic or it is totally missing. Return false otherwise.
     *         Note this method returns false if the service is only temporarily disabled, such as
     *         when it is updating.
     */
    public boolean isGooglePlayServicesMissing(final Context context) {
        final int resultCode = checkGooglePlayServicesAvailable(context);
        return (resultCode == ConnectionResult.SERVICE_MISSING
                || resultCode == ConnectionResult.SERVICE_INVALID);
    }

    /**
     * Checks whether Google Play Services can be used, applying the specified error-handling
     * policy if a user-recoverable error occurs. This method is threadsafe. If the specified
     * error-handling policy requires UI interaction, it will be run on the UI thread.
     * Subclasses should generally not override this method; instead, they should override the
     * helper methods {@link #checkGooglePlayServicesAvailable(Context)},
     * {@link #describeError(int)}, and {@link #isUserRecoverableError(int)} instead, which are
     * called in that order (as necessary) by this method.
     * @param errorHandler How to handle user-recoverable errors; must be non-null.
     * @return true if and only if Google Play Services can be used
     */
    public boolean canUseGooglePlayServices(final UserRecoverableErrorHandler errorHandler) {
        Context context = ContextUtils.getApplicationContext();
        final int resultCode = checkGooglePlayServicesAvailable(context);
        if (resultCode == ConnectionResult.SUCCESS) return true;
        // resultCode is some kind of error.
        Log.v(TAG, "Unable to use Google Play Services: %s", describeError(resultCode));
        if (isUserRecoverableError(resultCode)) {
            Runnable errorHandlerTask =
                    new Runnable() {
                        @Override
                        public void run() {
                            errorHandler.handleError(context, resultCode);
                        }
                    };
            PostTask.runOrPostTask(TaskTraits.UI_DEFAULT, errorHandlerTask);
        }
        return false;
    }

    /**
     * Shortcut of {@link #canUseGooglePlayServices(UserRecoverableErrorHandler)}.
     *
     * @return true if and only if Google Play Services can be used
     */
    public boolean canUseGooglePlayServices() {
        return canUseGooglePlayServices(new UserRecoverableErrorHandler.Silent());
    }

    /**
     * Same as {@link #canUseGooglePlayServices(UserRecoverableErrorHandler)}
     * but also with the constraint that first-party APIs must be available. This check is
     * implemented by verifying that the package is Google-signed; if not, first-party APIs will
     * be unavailable at runtime.
     * Nuance: The check on whether or not the package is Google-signed itself requires access to
     * Google Play Services, so this method first checks for "normal" (non-first-party) access and,
     * if successful, makes a second call to Google Play Services to determine the state of the
     * package signature. The failure handling policy only applies to the first check, since Google
     * Play Services provides "canned" ways to deal with failures; there is no special handling of
     * the case where the Google Play Services check succeeds and the Google-signed package check
     * fails (the method will simply return false).
     * @param userRecoverableErrorHandler How to handle user-recoverable errors from Google
     * Play Services; must be non-null.
     * @return true if and only if first-party Google Play Services can be used
     */
    @WorkerThread
    public boolean canUseFirstPartyGooglePlayServices(
            UserRecoverableErrorHandler userRecoverableErrorHandler) {
        return canUseGooglePlayServices(userRecoverableErrorHandler) && isChromeGoogleSigned();
    }

    /**
     * Shortcut of {@link #canUseFirstPartyGooglePlayServices(UserRecoverableErrorHandler)}.
     *
     * @return true if and only if first-party Google Play Services can be used
     */
    public boolean canUseFirstPartyGooglePlayServices() {
        return canUseFirstPartyGooglePlayServices(new UserRecoverableErrorHandler.Silent());
    }

    /** @return this object's {@link ExternalAuthGoogleDelegate} instance. */
    public ExternalAuthGoogleDelegate getGoogleDelegateForTesting() {
        return mGoogleDelegate;
    }

    /**
     * Invokes whatever external code is necessary to check if Google Play Services is available and
     * returns the code produced by the attempt. Subclasses can override to force the behavior one
     * way or another, or to change the way that the check is performed.
     *
     * @param context The current context.
     * @return The code produced by calling the external code
     */
    protected int checkGooglePlayServicesAvailable(final Context context) {
        // TODO(crbug.com/41233964): Temporarily allowing disk access until more permanent fix is
        // in.
        try (StrictModeContext ignored = StrictModeContext.allowDiskWrites();
                TraceEvent e = TraceEvent.scoped("checkGooglePlayServicesAvailable")) {
            return ChromiumPlayServicesAvailability.getGooglePlayServicesConnectionResult(context);
        }
    }

    /**
     * Invokes whatever external code is necessary to check if the specified error code produced
     * by {@link #checkGooglePlayServicesAvailable(Context)} represents a user-recoverable error.
     * Subclasses can override to filter error codes as desired.
     * @param errorCode The code to check
     * @return true If the code represents a user-recoverable error
     */
    protected boolean isUserRecoverableError(final int errorCode) {
        return GoogleApiAvailability.getInstance().isUserResolvableError(errorCode);
    }

    /**
     * Invokes whatever external code is necessary to obtain a textual description of an error
     * code produced by {@link #checkGooglePlayServicesAvailable(Context)}.
     * @param errorCode The code to check
     * @return a textual description of the error code
     */
    protected String describeError(final int errorCode) {
        return GoogleApiAvailability.getInstance().getErrorString(errorCode);
    }

    /**
     * Sets an instance for testing.
     * @param externalAuthUtils The instance to set for testing.
     */
    public static void setInstanceForTesting(ExternalAuthUtils externalAuthUtils) {
        var oldValue = sInstance;
        sInstance = externalAuthUtils;
        ResettersForTesting.register(() -> sInstance = oldValue);
    }
}