// Copyright 2017 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.payments;
import android.content.pm.PackageInfo;
import android.content.pm.ResolveInfo;
import android.content.pm.Signature;
import androidx.annotation.Nullable;
import org.chromium.base.Log;
import org.chromium.components.payments.PaymentManifestDownloader.ManifestDownloadCallback;
import org.chromium.components.payments.PaymentManifestParser.ManifestParseCallback;
import org.chromium.url.GURL;
import org.chromium.url.Origin;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Formatter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Verifies that the discovered native Android payment apps have the sufficient privileges
* to handle a single payment method. Downloads and parses the manifest to compare package
* names, versions, and signatures to the apps.
*
* Spec:
* https://docs.google.com/document/d/1izV4uC-tiRJG3JLooqY3YRLU22tYOsLTNq0P_InPJeE/edit#heading=h.cjp3jlnl47h5
*/
public class PaymentManifestVerifier
implements ManifestDownloadCallback,
ManifestParseCallback,
PaymentManifestWebDataService.PaymentManifestWebDataServiceCallback {
/** Interface for the callback to invoke when finished verification. */
public interface ManifestVerifyCallback {
/**
* Enables invoking the given native Android payment app for the given payment method as
* a default app. Called when the app has been found to have the right privileges to
* handle this payment method via a web app manifest that's one of the
* "default_applications".
*
* @param methodName The payment method name that the payment app offers to handle.
* @param resolveInfo Identifying information for the native Android payment app.
*/
void onValidDefaultPaymentApp(GURL methodName, ResolveInfo resolveInfo);
/**
* Enables native Android payment apps from the given origin to use this payment method
* name.
*
* @param methodName The payment method name that can be used.
* @param supportedOrigin The origin of the payment apps that can use the method name.
*/
void onValidSupportedOrigin(GURL methodName, GURL supportedOrigin);
/**
* Called when a part of verification has failed.
*
* @param errorMessage Developer facing error message.
*/
void onVerificationError(String errorMessage);
/**
* Called when the manifest has been fully verified. No more valid apps or origins will
* be found after this call.
*/
void onFinishedVerification();
/**
* Called when all the operations are done. After this call, the caller can release
* resources used by this class: cache, downloader, and parser.
*/
void onFinishedUsingResources();
}
/** Identifying information about an installed native Android payment app. */
private static class AppInfo {
/** Identifies a native Android payment app. */
public ResolveInfo resolveInfo;
/** The version code for the native Android payment app, e.g., 123. */
public long version;
/**
* The SHA256 certificate fingerprints for the native Android payment app, .e.g,
* ["308201dd30820146020101300d06092a864886f70d010105050030"].
*/
public Set<String> sha256CertFingerprints;
}
private static final String TAG = "PaymentManifest";
/**
* The origin of the iframe that invoked the PaymentRequest API. Used by security features like
* 'Sec-Fetch-Site' and 'Cross-Origin-Resource-Policy'.
*/
private final Origin mMerchantOrigin;
/**
* The payment method name that's being verified. The corresponding payment method manifest
* and default web app manifests will be downloaded, parsed, and cached.
*/
private final GURL mMethodName;
/** A mapping from the package name to the default application that matches the method name. */
private final Map<String, AppInfo> mDefaultApplications = new HashMap<>();
/** A set of origins of the non-default payment apps for the method name. */
private final Set<GURL> mSupportedOrigins;
/**
* A set of package names and origin names of the apps to cache. May also contain "*" to
* indicate that apps from all origins are supported.
*/
private final Set<String> mAppIdentifiersToCache = new HashSet<>();
/** A list of web app manifests to cache. */
private final List<WebAppManifestSection[]> mWebAppManifestsToCache = new ArrayList<>();
private final PaymentManifestWebDataService mCache;
private final PaymentManifestDownloader mDownloader;
private final PaymentManifestParser mParser;
private final PackageManagerDelegate mPackageManagerDelegate;
private final ManifestVerifyCallback mCallback;
private final MessageDigest mMessageDigest;
/** The origin of the payment method manifest after all redirects have been followed. */
private Origin mPaymentMethodManifestOrigin;
/**
* The number of web app manifests that have not yet been retrieved from cache or downloaded
* from the web.
*/
private int mPendingWebAppManifestsCount;
/** Whether the manifest cache is stale (unusable). */
private boolean mIsManifestCacheStaleOrUnusable;
/** Whether at least one payment method manifest or web app manifest failed to download or parse. */
private boolean mAtLeastOneManifestFailedToDownloadOrParse;
/**
* Builds the manifest verifier.
*
* @param merchantOrigin The origin of the iframe that invoked the PaymentRequest API.
* @param methodName The name of the payment method name that apps offer to handle.
* Must be an absolute URL with HTTPS scheme or HTTP localhost.
* @param defaultApplications The identifying information for the native Android payment apps
* that offer to handle this payment method as a default app,
* i.e., as one of the "default_applications". Can be null.
* @param supportedOrigins The origins of the apps that claim support of this payment
* method as their non-default, i.e., as one of the
* "supported_origins". Can be null.
* @param webDataService The web data service to cache manifest.
* @param downloader The manifest downloader.
* @param parser The manifest parser.
* @param packageManagerDelegate The package information retriever.
* @param callback The callback to be notified of verification result.
*/
public PaymentManifestVerifier(
Origin merchantOrigin,
GURL methodName,
@Nullable Set<ResolveInfo> defaultApplications,
@Nullable Set<GURL> supportedOrigins,
PaymentManifestWebDataService webDataService,
PaymentManifestDownloader downloader,
PaymentManifestParser parser,
PackageManagerDelegate packageManagerDelegate,
ManifestVerifyCallback callback) {
assert !methodName.getScheme().isEmpty();
mMerchantOrigin = merchantOrigin;
mMethodName = methodName;
if (defaultApplications != null) {
for (ResolveInfo defaultApp : defaultApplications) {
AppInfo appInfo = new AppInfo();
appInfo.resolveInfo = defaultApp;
mDefaultApplications.put(appInfo.resolveInfo.activityInfo.packageName, appInfo);
}
}
mSupportedOrigins =
Collections.unmodifiableSet(
supportedOrigins == null
? new HashSet<GURL>()
: new HashSet<>(supportedOrigins));
mDownloader = downloader;
mCache = webDataService;
mParser = parser;
mPackageManagerDelegate = packageManagerDelegate;
mCallback = callback;
MessageDigest md = null;
if (!mDefaultApplications.isEmpty()) {
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
// Intentionally ignore until verify() is called.
Log.e(TAG, "Unable to generate SHA-256 hashes.");
}
}
mMessageDigest = md;
}
/**
* Verifies that the discovered native Android payment apps have the sufficient privileges to
* handle this payment method.
*/
public void verify() {
if (!mDefaultApplications.isEmpty() && mMessageDigest == null) {
mCallback.onFinishedVerification();
mCallback.onFinishedUsingResources();
return;
}
List<String> invalidAppsToRemove = new ArrayList<>();
for (Map.Entry<String, AppInfo> entry : mDefaultApplications.entrySet()) {
String packageName = entry.getKey();
AppInfo appInfo = entry.getValue();
PackageInfo packageInfo =
mPackageManagerDelegate.getPackageInfoWithSignatures(packageName);
if (packageInfo == null) {
invalidAppsToRemove.add(packageName);
continue;
}
appInfo.version = packageInfo.versionCode;
appInfo.sha256CertFingerprints = new HashSet<>();
Signature[] signatures = packageInfo.signatures;
for (int i = 0; i < signatures.length; i++) {
mMessageDigest.update(signatures[i].toByteArray());
// The digest is reset after completing the hash computation.
appInfo.sha256CertFingerprints.add(byteArrayToString(mMessageDigest.digest()));
}
}
for (int i = 0; i < invalidAppsToRemove.size(); i++) {
mDefaultApplications.remove(invalidAppsToRemove.get(i));
}
// Try to fetch manifest from the cache first.
if (!mCache.getPaymentMethodManifest(mMethodName.toString(), this)) {
mIsManifestCacheStaleOrUnusable = true;
mDownloader.downloadPaymentMethodManifest(mMerchantOrigin, mMethodName, this);
}
}
/**
* Formats bytes into a string for easier comparison as a member of a set.
*
* @param input Input bytes.
* @return A string representation of the input bytes, e.g., "0123456789abcdef".
*/
private static String byteArrayToString(byte[] input) {
if (input == null) return null;
StringBuilder builder = new StringBuilder(input.length * 2);
Formatter formatter = new Formatter(builder);
for (byte b : input) {
formatter.format("%02x", b);
}
String result = builder.toString();
formatter.close();
return result;
}
@Override
public void onPaymentMethodManifestFetched(String[] appIdentifiers) {
Set<String> cachedDefaultAppPackageNames = new HashSet<>();
Set<GURL> cachedSupportedOrigins = new HashSet<>();
for (int i = 0; i < appIdentifiers.length; i++) {
if (appIdentifiers[i] == null) {
// The cache is stale. Download the manifest from the web instead.
mIsManifestCacheStaleOrUnusable = true;
mDownloader.downloadPaymentMethodManifest(mMerchantOrigin, mMethodName, this);
return;
}
GURL uriOrigin = new GURL(appIdentifiers[i]);
if (UrlUtil.isURLValid(uriOrigin)) {
cachedSupportedOrigins.add(uriOrigin);
continue;
}
cachedDefaultAppPackageNames.add(appIdentifiers[i]);
}
// The cache may be stale if it doesn't contain all matching apps, so download the
// manifest from the web instead.
if (appIdentifiers.length == 0
|| !cachedDefaultAppPackageNames.containsAll(mDefaultApplications.keySet())
|| !cachedSupportedOrigins.containsAll(mSupportedOrigins)) {
mIsManifestCacheStaleOrUnusable = true;
mDownloader.downloadPaymentMethodManifest(mMerchantOrigin, mMethodName, this);
return;
}
cachedSupportedOrigins.retainAll(mSupportedOrigins);
for (GURL validSupportedOrigin : cachedSupportedOrigins) {
mCallback.onValidSupportedOrigin(mMethodName, validSupportedOrigin);
}
if (mDefaultApplications.isEmpty()) {
mCallback.onFinishedVerification();
// Download and parse manifest to refresh cache.
mDownloader.downloadPaymentMethodManifest(mMerchantOrigin, mMethodName, this);
return;
}
mPendingWebAppManifestsCount = mDefaultApplications.size();
for (String matchingAppPackageName : mDefaultApplications.keySet()) {
if (!mCache.getPaymentWebAppManifest(matchingAppPackageName, this)) {
mIsManifestCacheStaleOrUnusable = true;
mPendingWebAppManifestsCount = 0;
mDownloader.downloadPaymentMethodManifest(mMerchantOrigin, mMethodName, this);
return;
}
}
}
@Override
public void onPaymentWebAppManifestFetched(WebAppManifestSection[] manifest) {
if (mIsManifestCacheStaleOrUnusable) return;
if (manifest == null || manifest.length == 0) {
mIsManifestCacheStaleOrUnusable = true;
mPendingWebAppManifestsCount = 0;
mDownloader.downloadPaymentMethodManifest(mMerchantOrigin, mMethodName, this);
return;
}
Set<String> validAppPackageNames = verifyAppWithWebAppManifest(manifest);
for (String validAppPackageName : validAppPackageNames) {
mCallback.onValidDefaultPaymentApp(
mMethodName, mDefaultApplications.get(validAppPackageName).resolveInfo);
}
mPendingWebAppManifestsCount--;
if (mPendingWebAppManifestsCount != 0) return;
mCallback.onFinishedVerification();
// Download and parse manifest to refresh cache.
mDownloader.downloadPaymentMethodManifest(mMerchantOrigin, mMethodName, this);
}
@Override
public void onPaymentMethodManifestDownloadSuccess(
GURL paymentMethodManifestUrl, Origin paymentMethodManifestOrigin, String content) {
assert mPaymentMethodManifestOrigin == null
: "Each verifier downloads exactly one payment method manifest file";
mPaymentMethodManifestOrigin = paymentMethodManifestOrigin;
mParser.parsePaymentMethodManifest(paymentMethodManifestUrl, content, this);
}
@Override
public void onPaymentMethodManifestParseSuccess(
GURL[] webAppManifestUris, GURL[] supportedOrigins) {
assert webAppManifestUris != null;
assert supportedOrigins != null;
assert webAppManifestUris.length > 0 || supportedOrigins.length > 0;
assert !mAtLeastOneManifestFailedToDownloadOrParse;
assert mPendingWebAppManifestsCount == 0;
Set<GURL> downloadedSupportedOrigins = new HashSet<>();
for (int i = 0; i < supportedOrigins.length; i++) {
downloadedSupportedOrigins.add(supportedOrigins[i]);
mAppIdentifiersToCache.add(supportedOrigins[i].getSpec());
}
if (mIsManifestCacheStaleOrUnusable) {
downloadedSupportedOrigins.retainAll(mSupportedOrigins);
for (GURL validSupportedOrigin : downloadedSupportedOrigins) {
mCallback.onValidSupportedOrigin(mMethodName, validSupportedOrigin);
}
}
if (webAppManifestUris.length == 0) {
if (mIsManifestCacheStaleOrUnusable) mCallback.onFinishedVerification();
// Cache supported package names and origins as well as possibly "*".
mCache.addPaymentMethodManifest(
mMethodName.getSpec(),
mAppIdentifiersToCache.toArray(new String[mAppIdentifiersToCache.size()]));
mCallback.onFinishedUsingResources();
return;
}
mPendingWebAppManifestsCount = webAppManifestUris.length;
for (int i = 0; i < webAppManifestUris.length; i++) {
if (mAtLeastOneManifestFailedToDownloadOrParse) return;
assert webAppManifestUris[i] != null;
mDownloader.downloadWebAppManifest(
mPaymentMethodManifestOrigin, webAppManifestUris[i], this);
}
}
@Override
public void onWebAppManifestDownloadSuccess(String content) {
if (mAtLeastOneManifestFailedToDownloadOrParse) return;
mParser.parseWebAppManifest(content, this);
}
@Override
public void onWebAppManifestParseSuccess(WebAppManifestSection[] manifest) {
assert manifest != null;
assert manifest.length > 0;
if (mAtLeastOneManifestFailedToDownloadOrParse) return;
for (int i = 0; i < manifest.length; i++) {
mAppIdentifiersToCache.add(manifest[i].id);
}
mWebAppManifestsToCache.add(manifest);
// Verify payment apps only if they have not already been verified by the cached manifest.
if (mIsManifestCacheStaleOrUnusable) {
Set<String> validAppPackageNames = verifyAppWithWebAppManifest(manifest);
for (String validAppPackageName : validAppPackageNames) {
mCallback.onValidDefaultPaymentApp(
mMethodName, mDefaultApplications.get(validAppPackageName).resolveInfo);
}
}
mPendingWebAppManifestsCount--;
if (mPendingWebAppManifestsCount != 0) return;
if (mIsManifestCacheStaleOrUnusable) mCallback.onFinishedVerification();
// Cache supported apps' package names and origins. (Also cache "*" if applicable.)
mCache.addPaymentMethodManifest(
mMethodName.toString(),
mAppIdentifiersToCache.toArray(new String[mAppIdentifiersToCache.size()]));
// Cache supported apps' parsed manifests.
mCache.addPaymentWebAppManifest(flattenListOfArrays(mWebAppManifestsToCache));
mCallback.onFinishedUsingResources();
}
/**
* Flattens a list of arrays into a single array.
*
* @param listOfLists A lists of arrays to flatten.
* @return The single array result.
*/
private static WebAppManifestSection[] flattenListOfArrays(
List<WebAppManifestSection[]> listOfLists) {
int totalNumberOfItems = 0;
for (int i = 0; i < listOfLists.size(); i++) {
totalNumberOfItems += listOfLists.get(i).length;
}
WebAppManifestSection[] flattenedList = new WebAppManifestSection[totalNumberOfItems];
for (int i = 0, k = 0; i < listOfLists.size(); i++) {
for (int j = 0; j < listOfLists.get(i).length; j++, k++) {
assert k < flattenedList.length;
flattenedList[k] = listOfLists.get(i)[j];
}
}
return flattenedList;
}
/**
* @return The set of package names of payment apps that match the manifest. Could be empty,
* but never null.
*/
private Set<String> verifyAppWithWebAppManifest(WebAppManifestSection[] manifest) {
assert manifest.length > 0;
List<Set<String>> sectionsFingerprints = new ArrayList<>();
for (int i = 0; i < manifest.length; i++) {
WebAppManifestSection section = manifest[i];
Set<String> fingerprints = new HashSet<>();
for (int j = 0; j < section.fingerprints.length; j++) {
fingerprints.add(byteArrayToString(section.fingerprints[j]));
}
sectionsFingerprints.add(fingerprints);
}
Set<String> packageNames = new HashSet<>();
for (int i = 0; i < manifest.length; i++) {
WebAppManifestSection section = manifest[i];
AppInfo appInfo = mDefaultApplications.get(section.id);
if (appInfo == null) continue;
if (appInfo.version < section.minVersion) {
Log.e(
TAG,
"\"%s\" version is %d, but at least %d is required.",
section.id,
appInfo.version,
section.minVersion);
continue;
}
if (appInfo.sha256CertFingerprints == null) {
Log.e(TAG, "Unable to determine fingerprints of \"%s\".", section.id);
continue;
}
if (!appInfo.sha256CertFingerprints.equals(sectionsFingerprints.get(i))) {
Log.e(
TAG,
"\"%s\" fingerprints don't match the manifest. Expected %s, but found %s.",
section.id,
setToString(sectionsFingerprints.get(i)),
setToString(appInfo.sha256CertFingerprints));
continue;
}
packageNames.add(section.id);
}
return packageNames;
}
private static String setToString(Set<String> set) {
StringBuilder result = new StringBuilder("[");
for (String item : set) {
result.append(' ');
result.append(item);
}
result.append(" ]");
return result.toString();
}
@Override
public void onManifestDownloadFailure(String errorMessage) {
if (mAtLeastOneManifestFailedToDownloadOrParse) return;
mAtLeastOneManifestFailedToDownloadOrParse = true;
mCallback.onVerificationError(errorMessage);
if (mIsManifestCacheStaleOrUnusable) mCallback.onFinishedVerification();
mCallback.onFinishedUsingResources();
}
@Override
public void onManifestParseFailure() {
if (mAtLeastOneManifestFailedToDownloadOrParse) return;
mAtLeastOneManifestFailedToDownloadOrParse = true;
if (mIsManifestCacheStaleOrUnusable) mCallback.onFinishedVerification();
mCallback.onFinishedUsingResources();
}
}