/*
* Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
*
* 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 com.scvngr.levelup.deeplinkauth;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.net.Uri;
import android.util.Log;
import com.scvngr.levelup.core.deeplinkauth.BuildConfig;
import com.scvngr.levelup.core.deeplinkauth.R;
import java.io.ByteArrayInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collection;
import java.util.Locale;
/**
* Utilities to support deep link authorization. This class is not intended to be used directly by
* 3rd party apps for integration; instead please see {@link LevelUpDeepLinkIntegrator}.
*/
public final class DeepLinkAuthUtil {
private static final String INTENT_PREFIX = "com.scvngr.levelup.core.";
/**
* Intent action for a permissions request.
*/
public static final String ACTION_REQUEST_PERMISSIONS =
INTENT_PREFIX + "ACTION_REQUEST_PERMISSIONS";
/**
* Intent extra containing the access token.
*/
public static final String EXTRA_STRING_ACCESS_TOKEN =
INTENT_PREFIX + "EXTRA_STRING_ACCESS_TOKEN";
/**
* The authorization request query parameter containing the web service ID of the app
* requesting the permissions.
*/
public static final String URI_QUERY_PARAMETER_APP_ID = "app_id";
/**
* The authorization request query parameter containing one of the permissions to be requested.
* The URI can contain multiple of these parameters in order to encode a request for multiple
* permissions.
*/
public static final String URI_QUERY_PARAMETER_PERMISSION = "permission";
/**
* A list of production package signatures that we're comfortable sending a permissions
* request to.
*/
private static final String[] LEVELUP_SIGNATURES_PRODUCTION = {
"5F:41:E0:40:8A:54:AA:2F:61:C6:CD:E0:CA:12:12:4D:9E:4A:6B:B5;com.scvngr.levelup.app",
};
/**
* A list of development package signatures that we're comfortable sending a permissions
* request to.
*/
private static final String[] LEVELUP_SIGNATURES_DEVELOPMENT = {
"5F:41:E0:40:8A:54:AA:2F:61:C6:CD:E0:CA:12:12:4D:9E:4A:6B:B5;com.scvngr.levelup.app",
"A3:87:3F:FE:AC:A7:FB:34:2C:63:6D:90:F6:05:61:8F:B0:6F:9D:C3;com.scvngr.levelup.app.development",
"A3:87:3F:FE:AC:A7:FB:34:2C:63:6D:90:F6:05:61:8F:B0:6F:9D:C3;com.scvngr.levelup.app",
};
/**
* A list of package signatures that we're comfortable sending a permissions request to.
*/
private static final String[] LEVELUP_SIGNATURES =
BuildConfig.DEBUG ? LEVELUP_SIGNATURES_DEVELOPMENT : LEVELUP_SIGNATURES_PRODUCTION;
/**
* Log tag.
*/
private static final String TAG = "DeepLinkAuthUtil";
/**
* @param context an Application context.
* @param request the intent in question.
* @return the Google Maps-style signature for the package that this intent resolves to or null
* if one could not be resolved.
* @see #getPackageSignature(android.content.Context, String)
*/
public static String getPackageSignature(final Context context, final Intent request) {
String signature = null;
final PackageManager pm = context.getPackageManager();
if (pm == null) {
throw new IllegalArgumentException("Provided context does not have a package manager");
}
final ActivityInfo activityInfo =
request.resolveActivityInfo(pm, PackageManager.GET_SIGNATURES);
if (activityInfo != null) {
final String packageName = activityInfo.packageName;
if (packageName != null) {
signature = getPackageSignature(context, packageName);
}
}
return signature;
}
/**
* Given a package, retrieves the Google Maps-style API key signature. This is in the form of
* {@code SIGNATURE;PACKAGE} where {@code SIGNATURE} is the signature of the package, in upper
* case hexadecimal characters, grouped in bytes by ":". {@code PACKAGE} is the provided
* package name.
*
* @param context an Application context.
* @param packageName the name of the package.
* @return the package's signature.
* @throws IllegalArgumentException if the provided package has no signatures or is otherwise
* unusable.
*/
public static String getPackageSignature(final Context context, final String packageName)
throws IllegalArgumentException {
final PackageManager pm = context.getPackageManager();
if (pm == null) {
throw new IllegalArgumentException("Provided context does not have a package manager");
}
try {
final PackageInfo packageInfo =
pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
final Signature[] signatures = packageInfo.signatures;
if (signatures == null || signatures.length == 0) {
throw new IllegalArgumentException("Provided package does not have any signatures");
}
final Signature signature = signatures[0];
final CertificateFactory certificateFactory = CertificateFactory.getInstance("X509");
final X509Certificate certificate = (X509Certificate) certificateFactory
.generateCertificate(new ByteArrayInputStream(signature.toByteArray()));
final String hexHash = getHexHash(certificate.getEncoded(), ':').toUpperCase(Locale.US);
final String signatureString = hexHash + ';' + packageName;
if (BuildConfig.DEBUG) {
Log.i(TAG, String.format(Locale.US, "Signature of package %s", signatureString));
}
return signatureString;
} catch (final PackageManager.NameNotFoundException e) {
throw new IllegalArgumentException("Package name not found", e);
} catch (final CertificateException e) {
throw new IllegalArgumentException("Error with provided certificate", e);
}
}
/**
* @param context application context.
* @return true if a version of LevelUp that supports deep link authorization is installed and
* available.
*/
public static boolean isLevelUpAvailable(final Context context) {
return isAuthenticLevelUp(context, toIntent(context, 0, Arrays.asList("test")),
LEVELUP_SIGNATURES);
}
/**
* Makes a request for permissions, verifying that the target of the Intent is authentically
* LevelUp.
*
* @param context application context.
* @param appId the web service ID of the app requesting the permissions.
* @param permissions the set of permissions.
* @return an intent intended to be sent with {@code startActivityForResult()}.
* @throws LevelUpNotInstalledException if LevelUp is not installed.
*/
public static Intent getRequestPermissionsIntent(final Context context, final int appId,
final Collection<String> permissions) throws LevelUpNotInstalledException {
return getRequestPermissionsIntent(context, appId, permissions, LEVELUP_SIGNATURES);
}
/**
* Makes a request for permissions, verifying that the target of the Intent is authentically
* LevelUp.
*
* @param context application context.
* @param appId the web service ID of the app requesting the permissions.
* @param permissions the set of permissions.
* @param authenticLevelUpSignatures the list of signatures that are allowed to handle the
* intent.
* @return an intent intended to be sent with {@code startActivityForResult()}.
* @throws LevelUpNotInstalledException if LevelUp is not installed.
*/
/* package */
static Intent getRequestPermissionsIntent(final Context context, final int appId,
final Collection<String> permissions, final String[] authenticLevelUpSignatures)
throws LevelUpNotInstalledException {
final Intent request = toIntent(context, appId, permissions);
if (!isAuthenticLevelUp(context, request, authenticLevelUpSignatures)) {
throw new LevelUpNotInstalledException();
}
return request;
}
/**
* @param context application context.
* @param appId the web service ID of the app requesting the permissions.
* @param permissions the set of permissions.
* @return an Intent that will load a permissions request.
*/
/* package */
static Intent toIntent(final Context context, final int appId,
final Collection<String> permissions) {
final Uri.Builder builder = new Uri.Builder();
builder.scheme(context.getText(R.string.levelup_app_url_scheme).toString());
builder.authority(context.getText(R.string.levelup_app_url_host_authorization).toString());
for (final String permission : permissions) {
builder.appendQueryParameter(URI_QUERY_PARAMETER_PERMISSION, permission);
}
builder.appendQueryParameter(URI_QUERY_PARAMETER_APP_ID, String.valueOf(appId));
return new Intent(ACTION_REQUEST_PERMISSIONS, builder.build());
}
/**
* @param data the input data.
* @param byteDelimiter a character to separate bytes or null if none is desired.
* @return the data as a hex string, optionally delimited by the byteDelimiter.
*/
/* package */
static String convertBytesToHex(final byte[] data, final Character byteDelimiter) {
final StringBuilder sb = new StringBuilder();
boolean needsSeparator = false;
for (final byte datum : data) {
if (needsSeparator && byteDelimiter != null) {
sb.append(byteDelimiter);
}
// get the unsigned portion
final int v = datum & 0xFF;
if (v < 0x10) {
sb.append('0');
}
sb.append(Integer.toHexString(v));
needsSeparator = true;
}
return sb.toString();
}
/**
* @param context an Application context.
* @param request the intent in question.
* @param authenticSignatures a list of signatures that match builds of LevelUp.
* @return returns true if the intent will resolve to one of the known LevelUp applications in
* {@link #LEVELUP_SIGNATURES}.
*/
/* package */
static boolean isAuthenticLevelUp(final Context context, final Intent request,
final String[] authenticSignatures) {
final String candidate = getPackageSignature(context, request);
return Arrays.asList(authenticSignatures).contains(candidate);
}
/**
* @param data the data to hash
* @param byteDelimiter an optional delimiter between bytes.
* @return a sha1 digest of the data, as a string with bytes delimited by byteDelimiter
*/
private static String getHexHash(final byte[] data, final Character byteDelimiter) {
try {
final byte[] hash = MessageDigest.getInstance("SHA1").digest(data);
return convertBytesToHex(hash, byteDelimiter);
} catch (final NoSuchAlgorithmException e) {
// this should never occur
throw new RuntimeException(e);
}
}
/**
* Thrown if LevelUp is not installed.
*/
public static final class LevelUpNotInstalledException extends Exception {
private static final long serialVersionUID = 9096442582651996712L;
public LevelUpNotInstalledException() {
super("The package trying to handle a LevelUp permissions request isn't a known LevelUp app");
}
}
/**
* This is a utility class and cannot be instantiated.
*/
private DeepLinkAuthUtil() {
throw new UnsupportedOperationException("This class cannot be instantiated.");
}
}