Implement IntentFilter verification service.

This commit adds a verifier that verifies a host delegates permission for
an app to handle Url for the host using the Statement protocol.

- Implements the Statement protocol
-- The protocol defines a file format that represents statements.
-- The protocol defines where each asset type should put their statement
declaration. For web asset, the statement file should be hosted at
<scheme>://<host>:<port>/.well-known/associations.json.

- Implements IntentFilterVerificationReceiver, an interface between
StatementService and PackageManager. PackageManager will send a
broadcast with action Intent.ACTION_INTENT_FILTER_NEEDS_VERIFICATION.
The service will process the request and returns the results by calling
PackageManager.verifyIntentFilter().

To verify an IntentFilter like this defined in Android app com.test.app
<intent-filter>
  <data android:scheme="https" />
  <data android:host="www.test.com" />
  <data android:pathPattern=".*"/>
</intent-filter>

The service will try to retrieve the statement file from
https://www.test.com:443/.well-known/associations.json and try to find
a JSON object equivalent to
{'relation': ['delegate_permission/common.handle_all_urls'],
 'target': {'namespace': 'android_app',
            'package_name': 'com.test.app',
            'sha256_cert_fingerprints': [APP_CERT_FP]}}
The entry should have the correct relation, package name, and
certificate sha256 fingerprint.

Because this implementation will send a HTTP request for each host
specified in the intent-filter in AndroidManifest.xml, to avoid overwhelming
the network at app install time, we limit the maximum number of hosts we will
verify for a single app to 10. Any app with more than 10 hosts in the
autoVerify=true intent-filter won't be auto verified.

Change-Id: I787c9d176e4110aa441eb5fe4fa9651a071c6610
This commit is contained in:
Joseph Wen
2015-02-25 14:00:39 -05:00
parent b43755be6e
commit 6a34bb2d6a
27 changed files with 2592 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
# Copyright (C) 2015 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.
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE_TAGS := optional
LOCAL_SRC_FILES := $(call all-java-files-under, src)
LOCAL_PROGUARD_FLAG_FILES := proguard.flags
LOCAL_PACKAGE_NAME := StatementService
LOCAL_PRIVILEGED_MODULE := true
LOCAL_STATIC_JAVA_LIBRARIES := \
libprotobuf-java-nano \
volley
include $(BUILD_PACKAGE)
include $(call all-makefiles-under,$(LOCAL_PATH)/src)

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2015 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.statementservice"
android:versionCode="1"
android:versionName="1.0">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTENT_FILTER_VERIFICATION_AGENT"/>
<application
android:label="@string/service_name"
android:allowBackup="false">
<service
android:name=".DirectStatementService"
android:exported="false">
<intent-filter>
<category android:name="android.intent.category.DEFAULT"/>
<action android:name="com.android.statementservice.aosp.service.CHECK_ACTION"/>
</intent-filter>
</service>
<receiver
android:name=".IntentFilterVerificationReceiver"
android:permission="android.permission.BIND_INTENT_FILTER_VERIFIER">
<!-- Set the priority 1 so newer implementation can have higher priority. -->
<intent-filter
android:priority="1">
<action android:name="android.intent.action.INTENT_FILTER_NEEDS_VERIFICATION"/>
<data android:mimeType="application/vnd.android.package-archive"/>
</intent-filter>
</receiver>
</application>
</manifest>

View File

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2015 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.
-->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="service_name">Intent Filter Verification Service</string>
</resources>

View File

@@ -0,0 +1,290 @@
/*
* Copyright (C) 2015 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 com.android.statementservice;
import android.app.Service;
import android.content.Intent;
import android.net.http.HttpResponseCache;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.ResultReceiver;
import android.util.Log;
import com.android.statementservice.retriever.AbstractAsset;
import com.android.statementservice.retriever.AbstractAssetMatcher;
import com.android.statementservice.retriever.AbstractStatementRetriever;
import com.android.statementservice.retriever.AbstractStatementRetriever.Result;
import com.android.statementservice.retriever.AssociationServiceException;
import com.android.statementservice.retriever.Relation;
import com.android.statementservice.retriever.Statement;
import org.json.JSONException;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
/**
* Handles com.android.statementservice.service.CHECK_ALL_ACTION intents.
*/
public final class DirectStatementService extends Service {
private static final String TAG = DirectStatementService.class.getSimpleName();
/**
* Returns true if every asset in {@code SOURCE_ASSET_DESCRIPTORS} is associated with {@code
* EXTRA_TARGET_ASSET_DESCRIPTOR} for {@code EXTRA_RELATION} relation.
*
* <p>Takes parameter {@code EXTRA_RELATION}, {@code SOURCE_ASSET_DESCRIPTORS}, {@code
* EXTRA_TARGET_ASSET_DESCRIPTOR}, and {@code EXTRA_RESULT_RECEIVER}.
*/
public static final String CHECK_ALL_ACTION =
"com.android.statementservice.service.CHECK_ALL_ACTION";
/**
* Parameter for {@link #CHECK_ALL_ACTION}.
*
* <p>A relation string.
*/
public static final String EXTRA_RELATION =
"com.android.statementservice.service.RELATION";
/**
* Parameter for {@link #CHECK_ALL_ACTION}.
*
* <p>An array of asset descriptors in JSON.
*/
public static final String EXTRA_SOURCE_ASSET_DESCRIPTORS =
"com.android.statementservice.service.SOURCE_ASSET_DESCRIPTORS";
/**
* Parameter for {@link #CHECK_ALL_ACTION}.
*
* <p>An asset descriptor in JSON.
*/
public static final String EXTRA_TARGET_ASSET_DESCRIPTOR =
"com.android.statementservice.service.TARGET_ASSET_DESCRIPTOR";
/**
* Parameter for {@link #CHECK_ALL_ACTION}.
*
* <p>A {@code ResultReceiver} instance that will be used to return the result. If the request
* failed, return {@link #RESULT_FAIL} and an empty {@link android.os.Bundle}. Otherwise, return
* {@link #RESULT_SUCCESS} and a {@link android.os.Bundle} with the result stored in {@link
* #IS_ASSOCIATED}.
*/
public static final String EXTRA_RESULT_RECEIVER =
"com.android.statementservice.service.RESULT_RECEIVER";
/**
* A boolean bundle entry that stores the result of {@link #CHECK_ALL_ACTION}.
* This is set only if the service returns with {@code RESULT_SUCCESS}.
* {@code IS_ASSOCIATED} is true if and only if {@code FAILED_SOURCES} is empty.
*/
public static final String IS_ASSOCIATED = "is_associated";
/**
* A String ArrayList bundle entry that stores sources that can't be verified.
*/
public static final String FAILED_SOURCES = "failed_sources";
/**
* Returned by the service if the request is successfully processed. The caller should check
* the {@code IS_ASSOCIATED} field to determine if the association exists or not.
*/
public static final int RESULT_SUCCESS = 0;
/**
* Returned by the service if the request failed. The request will fail if, for example, the
* input is not well formed, or the network is not available.
*/
public static final int RESULT_FAIL = 1;
private static final long HTTP_CACHE_SIZE_IN_BYTES = 1 * 1024 * 1024; // 1 MBytes
private static final String CACHE_FILENAME = "request_cache";
private AbstractStatementRetriever mStatementRetriever;
private Handler mHandler;
private HandlerThread mThread;
private HttpResponseCache mHttpResponseCache;
@Override
public void onCreate() {
mThread = new HandlerThread("DirectStatementService thread",
android.os.Process.THREAD_PRIORITY_BACKGROUND);
mThread.start();
onCreate(AbstractStatementRetriever.createDirectRetriever(this), mThread.getLooper(),
getCacheDir());
}
/**
* Creates a DirectStatementService with the dependencies passed in for easy testing.
*/
public void onCreate(AbstractStatementRetriever statementRetriever, Looper looper,
File cacheDir) {
super.onCreate();
mStatementRetriever = statementRetriever;
mHandler = new Handler(looper);
try {
File httpCacheDir = new File(cacheDir, CACHE_FILENAME);
mHttpResponseCache = HttpResponseCache.install(httpCacheDir, HTTP_CACHE_SIZE_IN_BYTES);
} catch (IOException e) {
Log.i(TAG, "HTTPS response cache installation failed:" + e);
}
}
@Override
public void onDestroy() {
super.onDestroy();
if (mThread != null) {
mThread.quit();
}
try {
if (mHttpResponseCache != null) {
mHttpResponseCache.delete();
}
} catch (IOException e) {
Log.i(TAG, "HTTP(S) response cache deletion failed:" + e);
}
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
super.onStartCommand(intent, flags, startId);
if (intent == null) {
Log.e(TAG, "onStartCommand called with null intent");
return START_STICKY;
}
if (intent.getAction().equals(CHECK_ALL_ACTION)) {
Bundle extras = intent.getExtras();
List<String> sources = extras.getStringArrayList(EXTRA_SOURCE_ASSET_DESCRIPTORS);
String target = extras.getString(EXTRA_TARGET_ASSET_DESCRIPTOR);
String relation = extras.getString(EXTRA_RELATION);
ResultReceiver resultReceiver = extras.getParcelable(EXTRA_RESULT_RECEIVER);
if (resultReceiver == null) {
Log.e(TAG, " Intent does not have extra " + EXTRA_RESULT_RECEIVER);
return START_STICKY;
}
if (sources == null) {
Log.e(TAG, " Intent does not have extra " + EXTRA_SOURCE_ASSET_DESCRIPTORS);
resultReceiver.send(RESULT_FAIL, Bundle.EMPTY);
return START_STICKY;
}
if (target == null) {
Log.e(TAG, " Intent does not have extra " + EXTRA_TARGET_ASSET_DESCRIPTOR);
resultReceiver.send(RESULT_FAIL, Bundle.EMPTY);
return START_STICKY;
}
if (relation == null) {
Log.e(TAG, " Intent does not have extra " + EXTRA_RELATION);
resultReceiver.send(RESULT_FAIL, Bundle.EMPTY);
return START_STICKY;
}
mHandler.post(new ExceptionLoggingFutureTask<Void>(
new IsAssociatedCallable(sources, target, relation, resultReceiver), TAG));
} else {
Log.e(TAG, "onStartCommand called with unsupported action: " + intent.getAction());
}
return START_STICKY;
}
private class IsAssociatedCallable implements Callable<Void> {
private List<String> mSources;
private String mTarget;
private String mRelation;
private ResultReceiver mResultReceiver;
public IsAssociatedCallable(List<String> sources, String target, String relation,
ResultReceiver resultReceiver) {
mSources = sources;
mTarget = target;
mRelation = relation;
mResultReceiver = resultReceiver;
}
private boolean verifyOneSource(AbstractAsset source, AbstractAssetMatcher target,
Relation relation) throws AssociationServiceException {
Result statements = mStatementRetriever.retrieveStatements(source);
for (Statement statement : statements.getStatements()) {
if (relation.matches(statement.getRelation())
&& target.matches(statement.getTarget())) {
return true;
}
}
return false;
}
@Override
public Void call() {
Bundle result = new Bundle();
ArrayList<String> failedSources = new ArrayList<String>();
AbstractAssetMatcher target;
Relation relation;
try {
target = AbstractAssetMatcher.createMatcher(mTarget);
relation = Relation.create(mRelation);
} catch (AssociationServiceException | JSONException e) {
Log.e(TAG, "isAssociatedCallable failed with exception", e);
mResultReceiver.send(RESULT_FAIL, Bundle.EMPTY);
return null;
}
boolean allSourcesVerified = true;
for (String sourceString : mSources) {
AbstractAsset source;
try {
source = AbstractAsset.create(sourceString);
} catch (AssociationServiceException e) {
mResultReceiver.send(RESULT_FAIL, Bundle.EMPTY);
return null;
}
try {
if (!verifyOneSource(source, target, relation)) {
failedSources.add(source.toJson());
allSourcesVerified = false;
}
} catch (AssociationServiceException e) {
failedSources.add(source.toJson());
allSourcesVerified = false;
}
}
result.putBoolean(IS_ASSOCIATED, allSourcesVerified);
result.putStringArrayList(FAILED_SOURCES, failedSources);
mResultReceiver.send(RESULT_SUCCESS, result);
return null;
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (C) 2015 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 com.android.statementservice;
import android.util.Log;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
* {@link FutureTask} that logs unhandled exceptions.
*/
final class ExceptionLoggingFutureTask<V> extends FutureTask<V> {
private final String mTag;
public ExceptionLoggingFutureTask(Callable<V> callable, String tag) {
super(callable);
mTag = tag;
}
@Override
protected void done() {
try {
get();
} catch (ExecutionException | InterruptedException e) {
Log.e(mTag, "Uncaught exception.", e);
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,195 @@
/*
* Copyright (C) 2015 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 com.android.statementservice;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Bundle;
import android.os.Handler;
import android.os.ResultReceiver;
import android.util.Log;
import android.util.Patterns;
import com.android.statementservice.retriever.Utils;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
/**
* Receives {@link Intent#ACTION_INTENT_FILTER_NEEDS_VERIFICATION} broadcast and calls
* {@link DirectStatementService} to verify the request. Calls
* {@link PackageManager#verifyIntentFilter} to notify {@link PackageManager} the result of the
* verification.
*
* This implementation of the API will send a HTTP request for each host specified in the query.
* To avoid overwhelming the network at app install time, {@code MAX_HOSTS_PER_REQUEST} limits
* the maximum number of hosts in a query. If a query contains more than
* {@code MAX_HOSTS_PER_REQUEST} hosts, it will fail immediately without making any HTTP request
* and call {@link PackageManager#verifyIntentFilter} with
* {@link PackageManager#INTENT_FILTER_VERIFICATION_FAILURE}.
*/
public final class IntentFilterVerificationReceiver extends BroadcastReceiver {
private static final String TAG = IntentFilterVerificationReceiver.class.getSimpleName();
private static final Integer MAX_HOSTS_PER_REQUEST = 10;
private static final String HANDLE_ALL_URLS_RELATION
= "delegate_permission/common.handle_all_urls";
private static final String ANDROID_ASSET_FORMAT = "{\"namespace\": \"android_app\", "
+ "\"package_name\": \"%s\", \"sha256_cert_fingerprints\": [\"%s\"]}";
private static final String WEB_ASSET_FORMAT = "{\"namespace\": \"web\", \"site\": \"%s\"}";
private static final Pattern ANDROID_PACKAGE_NAME_PATTERN =
Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)*$");
private static final String TOO_MANY_HOSTS_FORMAT =
"Request contains %d hosts which is more than the allowed %d.";
private static void sendErrorToPackageManager(PackageManager packageManager,
int verificationId) {
packageManager.verifyIntentFilter(verificationId,
PackageManager.INTENT_FILTER_VERIFICATION_FAILURE,
Collections.<String>emptyList());
}
@Override
public void onReceive(Context context, Intent intent) {
final String action = intent.getAction();
if (Intent.ACTION_INTENT_FILTER_NEEDS_VERIFICATION.equals(action)) {
Bundle inputExtras = intent.getExtras();
if (inputExtras != null) {
Intent serviceIntent = new Intent(context, DirectStatementService.class);
serviceIntent.setAction(DirectStatementService.CHECK_ALL_ACTION);
int verificationId = inputExtras.getInt(
PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_ID);
String scheme = inputExtras.getString(
PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_URI_SCHEME);
String hosts = inputExtras.getString(
PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_HOSTS);
String packageName = inputExtras.getString(
PackageManager.EXTRA_INTENT_FILTER_VERIFICATION_PACKAGE_NAME);
Log.i(TAG, "Verify IntentFilter for " + hosts);
Bundle extras = new Bundle();
extras.putString(DirectStatementService.EXTRA_RELATION, HANDLE_ALL_URLS_RELATION);
String[] hostList = hosts.split(" ");
if (hostList.length > MAX_HOSTS_PER_REQUEST) {
Log.w(TAG, String.format(TOO_MANY_HOSTS_FORMAT,
hostList.length, MAX_HOSTS_PER_REQUEST));
sendErrorToPackageManager(context.getPackageManager(), verificationId);
return;
}
try {
ArrayList<String> sourceAssets = new ArrayList<String>();
for (String host : hostList) {
sourceAssets.add(createWebAssetString(scheme, host));
}
extras.putStringArrayList(DirectStatementService.EXTRA_SOURCE_ASSET_DESCRIPTORS,
sourceAssets);
} catch (MalformedURLException e) {
Log.w(TAG, "Error when processing input host: " + e.getMessage());
sendErrorToPackageManager(context.getPackageManager(), verificationId);
return;
}
try {
extras.putString(DirectStatementService.EXTRA_TARGET_ASSET_DESCRIPTOR,
createAndroidAssetString(context, packageName));
} catch (NameNotFoundException e) {
Log.w(TAG, "Error when processing input Android package: " + e.getMessage());
sendErrorToPackageManager(context.getPackageManager(), verificationId);
return;
}
extras.putParcelable(DirectStatementService.EXTRA_RESULT_RECEIVER,
new IsAssociatedResultReceiver(
new Handler(), context.getPackageManager(), verificationId));
serviceIntent.putExtras(extras);
context.startService(serviceIntent);
}
} else {
Log.w(TAG, "Intent action not supported: " + action);
}
}
private String createAndroidAssetString(Context context, String packageName)
throws NameNotFoundException {
if (!ANDROID_PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
throw new NameNotFoundException("Input package name is not valid.");
}
List<String> certFingerprints =
Utils.getCertFingerprintsFromPackageManager(packageName, context);
return String.format(ANDROID_ASSET_FORMAT, packageName,
Utils.joinStrings("\", \"", certFingerprints));
}
private String createWebAssetString(String scheme, String host) throws MalformedURLException {
if (!Patterns.DOMAIN_NAME.matcher(host).matches()) {
throw new MalformedURLException("Input host is not valid.");
}
if (!scheme.equals("http") && !scheme.equals("https")) {
throw new MalformedURLException("Input scheme is not valid.");
}
return String.format(WEB_ASSET_FORMAT, new URL(scheme, host, "").toString());
}
/**
* Receives the result of {@code StatementService.CHECK_ACTION} from
* {@link DirectStatementService} and passes it back to {@link PackageManager}.
*/
private static class IsAssociatedResultReceiver extends ResultReceiver {
private final int mVerificationId;
private final PackageManager mPackageManager;
public IsAssociatedResultReceiver(Handler handler, PackageManager packageManager,
int verificationId) {
super(handler);
mVerificationId = verificationId;
mPackageManager = packageManager;
}
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
if (resultCode == DirectStatementService.RESULT_SUCCESS) {
if (resultData.getBoolean(DirectStatementService.IS_ASSOCIATED)) {
mPackageManager.verifyIntentFilter(mVerificationId,
PackageManager.INTENT_FILTER_VERIFICATION_SUCCESS,
Collections.<String>emptyList());
} else {
mPackageManager.verifyIntentFilter(mVerificationId,
PackageManager.INTENT_FILTER_VERIFICATION_FAILURE,
resultData.getStringArrayList(DirectStatementService.FAILED_SOURCES));
}
} else {
sendErrorToPackageManager(mPackageManager, mVerificationId);
}
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
/**
* A handle representing the identity and address of some digital asset. An asset is an online
* entity that typically provides some service or content. Examples of assets are websites, Android
* apps, Twitter feeds, and Plus Pages.
*
* <p> Asset can be represented by a JSON string. For example, the web site https://www.google.com
* can be represented by
* <pre>
* {"namespace": "web", "site": "https://www.google.com"}
* </pre>
*
* <p> The Android app with package name com.google.test that is signed by a certificate with sha256
* fingerprint 11:22:33 can be represented by
* <pre>
* {"namespace": "android_app",
* "package_name": "com.google.test",
* "sha256_cert_fingerprints": ["11:22:33"]}
* </pre>
*
* <p>Given a signed APK, Java 7's commandline keytool can compute the fingerprint using:
* {@code keytool -list -printcert -jarfile signed_app.apk}
*/
public abstract class AbstractAsset {
/**
* Returns a JSON string representation of this asset. The strings returned by this function are
* normalized -- they can be used for equality testing.
*/
public abstract String toJson();
/**
* Returns a key that can be used by {@link AbstractAssetMatcher} to lookup the asset.
*
* <p> An asset will match an {@code AssetMatcher} only if the value of this method is equal to
* {@code AssetMatcher.getMatchedLookupKey()}.
*/
public abstract int lookupKey();
/**
* Creates a new Asset from its JSON string representation.
*
* @throws AssociationServiceException if the assetJson is not well formatted.
*/
public static AbstractAsset create(String assetJson)
throws AssociationServiceException {
return AssetFactory.create(assetJson);
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import org.json.JSONException;
/**
* An asset matcher that can match asset with the given query.
*/
public abstract class AbstractAssetMatcher {
/**
* Returns true if this AssetMatcher matches the asset.
*/
public abstract boolean matches(AbstractAsset asset);
/**
* This AssetMatcher will only match Asset with {@code lookupKey()} equal to the value returned
* by this method.
*/
public abstract int getMatchedLookupKey();
/**
* Creates a new AssetMatcher from its JSON string representation.
*
* <p> For web namespace, {@code query} will match assets that have the same 'site' field.
*
* <p> For Android namespace, {@code query} will match assets that have the same
* 'package_name' field and have at least one common certificate fingerprint in
* 'sha256_cert_fingerprints' field.
*/
public static AbstractAssetMatcher createMatcher(String query)
throws AssociationServiceException, JSONException {
return AssetMatcherFactory.create(query);
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import android.content.Context;
import android.annotation.NonNull;
import java.util.List;
/**
* Retrieves the statements made by assets. This class is the entry point of the package.
* <p>
* An asset is an identifiable and addressable online entity that typically
* provides some service or content. Examples of assets are websites, Android
* apps, Twitter feeds, and Plus Pages.
* <p>
* Ownership of an asset is defined by being able to control it and speak for it.
* An asset owner may establish a relationship between the asset and another
* asset by making a statement about an intended relationship between the two.
* An example of a relationship is permission delegation. For example, the owner
* of a website (the webmaster) may delegate the ability the handle URLs to a
* particular mobile app. Relationships are considered public information.
* <p>
* A particular kind of relationship (like permission delegation) defines a binary
* relation on assets. The relation is not symmetric or transitive, nor is it
* antisymmetric or anti-transitive.
* <p>
* A statement S(r, a, b) is an assertion that the relation r holds for the
* ordered pair of assets (a, b). For example, taking r = "delegates permission
* to view user's location", a = New York Times mobile app,
* b = nytimes.com website, S(r, a, b) would be an assertion that "the New York
* Times mobile app delegates its ability to use the user's location to the
* nytimes.com website".
* <p>
* A statement S(r, a, b) is considered <b>reliable</b> if we have confidence that
* the statement is true; the exact criterion depends on the kind of statement,
* since some kinds of statements may be true on their face whereas others may
* require multiple parties to agree.
* <p>
* For example, to get the statements made by www.example.com use:
* <pre>
* result = retrieveStatements(AssetFactory.create(
* "{\"namespace\": \"web\", \"site\": \"https://www.google.com\"}"))
* </pre>
* {@code result} will contain the statements and the expiration time of this result. The statements
* are considered reliable until the expiration time.
*/
public abstract class AbstractStatementRetriever {
/**
* Returns the statements made by the {@code source} asset with ttl.
*
* @throws AssociationServiceException if the asset namespace is not supported.
*/
public abstract Result retrieveStatements(AbstractAsset source)
throws AssociationServiceException;
/**
* The retrieved statements and the expiration date.
*/
public interface Result {
/**
* @return the retrieved statements.
*/
@NonNull
public List<Statement> getStatements();
/**
* @return the expiration time in millisecond.
*/
public long getExpireMillis();
}
/**
* Creates a new StatementRetriever that directly retrieves statements from the asset.
*
* <p> For web assets, {@link AbstractStatementRetriever} will try to retrieve the statement
* file from URL: {@code [webAsset.site]/.well-known/associations.json"} where {@code
* [webAsset.site]} is in the form {@code http{s}://[hostname]:[optional_port]}. The file
* should contain one JSON array of statements.
*
* <p> For Android assets, {@link AbstractStatementRetriever} will try to retrieve the statement
* from the AndroidManifest.xml. The developer should add a {@code meta-data} tag under
* {@code application} tag where attribute {@code android:name} equals "associated_assets"
* and {@code android:recourse} points to a string array resource. Each entry in the string
* array should contain exactly one statement in JSON format. Note that this implementation
* can only return statements made by installed apps.
*/
public static AbstractStatementRetriever createDirectRetriever(Context context) {
return new DirectStatementRetriever(new URLFetcher(),
new AndroidPackageInfoFetcher(context));
}
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
/**
* Immutable value type that names an Android app asset.
*
* <p>An Android app can be named by its package name and certificate fingerprints using this JSON
* string: { "namespace": "android_app", "package_name": "[Java package name]",
* "sha256_cert_fingerprints": ["[SHA256 fingerprint of signing cert]", "[additional cert]", ...] }
*
* <p>For example, { "namespace": "android_app", "package_name": "com.test.mytestapp",
* "sha256_cert_fingerprints": ["24:D9:B4:57:A6:42:FB:E6:E5:B8:D6:9E:7B:2D:C2:D1:CB:D1:77:17:1D:7F:D4:A9:16:10:11:AB:92:B9:8F:3F"]
* }
*
* <p>Given a signed APK, Java 7's commandline keytool can compute the fingerprint using:
* {@code keytool -list -printcert -jarfile signed_app.apk}
*
* <p>Each entry in "sha256_cert_fingerprints" is a colon-separated hex string (e.g. 14:6D:E9:...)
* representing the certificate SHA-256 fingerprint.
*/
/* package private */ final class AndroidAppAsset extends AbstractAsset {
private static final String MISSING_FIELD_FORMAT_STRING = "Expected %s to be set.";
private static final String MISSING_APPCERTS_FORMAT_STRING =
"Expected %s to be non-empty array.";
private static final String APPCERT_NOT_STRING_FORMAT_STRING = "Expected all %s to be strings.";
private final List<String> mCertFingerprints;
private final String mPackageName;
public List<String> getCertFingerprints() {
return Collections.unmodifiableList(mCertFingerprints);
}
public String getPackageName() {
return mPackageName;
}
@Override
public String toJson() {
AssetJsonWriter writer = new AssetJsonWriter();
writer.writeFieldLower(Utils.NAMESPACE_FIELD, Utils.NAMESPACE_ANDROID_APP);
writer.writeFieldLower(Utils.ANDROID_APP_ASSET_FIELD_PACKAGE_NAME, mPackageName);
writer.writeArrayUpper(Utils.ANDROID_APP_ASSET_FIELD_CERT_FPS, mCertFingerprints);
return writer.closeAndGetString();
}
@Override
public String toString() {
StringBuilder asset = new StringBuilder();
asset.append("AndroidAppAsset: ");
asset.append(toJson());
return asset.toString();
}
@Override
public boolean equals(Object o) {
if (!(o instanceof AndroidAppAsset)) {
return false;
}
return ((AndroidAppAsset) o).toJson().equals(toJson());
}
@Override
public int hashCode() {
return toJson().hashCode();
}
@Override
public int lookupKey() {
return getPackageName().hashCode();
}
/**
* Checks that the input is a valid Android app asset.
*
* @param asset a JSONObject that has "namespace", "package_name", and
* "sha256_cert_fingerprints" fields.
* @throws AssociationServiceException if the asset is not well formatted.
*/
public static AndroidAppAsset create(JSONObject asset)
throws AssociationServiceException {
String packageName = asset.optString(Utils.ANDROID_APP_ASSET_FIELD_PACKAGE_NAME);
if (packageName.equals("")) {
throw new AssociationServiceException(String.format(MISSING_FIELD_FORMAT_STRING,
Utils.ANDROID_APP_ASSET_FIELD_PACKAGE_NAME));
}
JSONArray certArray = asset.optJSONArray(Utils.ANDROID_APP_ASSET_FIELD_CERT_FPS);
if (certArray == null || certArray.length() == 0) {
throw new AssociationServiceException(
String.format(MISSING_APPCERTS_FORMAT_STRING,
Utils.ANDROID_APP_ASSET_FIELD_CERT_FPS));
}
List<String> certFingerprints = new ArrayList<>(certArray.length());
for (int i = 0; i < certArray.length(); i++) {
try {
certFingerprints.add(certArray.getString(i));
} catch (JSONException e) {
throw new AssociationServiceException(
String.format(APPCERT_NOT_STRING_FORMAT_STRING,
Utils.ANDROID_APP_ASSET_FIELD_CERT_FPS));
}
}
return new AndroidAppAsset(packageName, certFingerprints);
}
/**
* Creates a new AndroidAppAsset.
*
* @param packageName the package name of the Android app.
* @param certFingerprints at least one of the Android app signing certificate sha-256
* fingerprint.
*/
public static AndroidAppAsset create(String packageName, List<String> certFingerprints) {
if (packageName == null || packageName.equals("")) {
throw new AssertionError("Expected packageName to be set.");
}
if (certFingerprints == null || certFingerprints.size() == 0) {
throw new AssertionError("Expected certFingerprints to be set.");
}
List<String> lowerFps = new ArrayList<String>(certFingerprints.size());
for (String fp : certFingerprints) {
lowerFps.add(fp.toUpperCase(Locale.US));
}
return new AndroidAppAsset(packageName, lowerFps);
}
private AndroidAppAsset(String packageName, List<String> certFingerprints) {
if (packageName.equals("")) {
mPackageName = null;
} else {
mPackageName = packageName;
}
if (certFingerprints == null || certFingerprints.size() == 0) {
mCertFingerprints = null;
} else {
mCertFingerprints = Collections.unmodifiableList(sortAndDeDuplicate(certFingerprints));
}
}
/**
* Returns an ASCII-sorted copy of the list of certs with all duplicates removed.
*/
private List<String> sortAndDeDuplicate(List<String> certs) {
if (certs.size() <= 1) {
return certs;
}
HashSet<String> set = new HashSet<>(certs);
List<String> result = new ArrayList<>(set);
Collections.sort(result);
return result;
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import java.util.HashSet;
import java.util.Set;
/**
* Match assets that have the same 'package_name' field and have at least one common certificate
* fingerprint in 'sha256_cert_fingerprints' field.
*/
/* package private */ final class AndroidAppAssetMatcher extends AbstractAssetMatcher {
private final AndroidAppAsset mQuery;
public AndroidAppAssetMatcher(AndroidAppAsset query) {
mQuery = query;
}
@Override
public boolean matches(AbstractAsset asset) {
if (asset instanceof AndroidAppAsset) {
AndroidAppAsset androidAppAsset = (AndroidAppAsset) asset;
if (!androidAppAsset.getPackageName().equals(mQuery.getPackageName())) {
return false;
}
Set<String> certs = new HashSet<String>(mQuery.getCertFingerprints());
for (String cert : androidAppAsset.getCertFingerprints()) {
if (certs.contains(cert)) {
return true;
}
}
}
return false;
}
@Override
public int getMatchedLookupKey() {
return mQuery.lookupKey();
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.Resources.NotFoundException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Class that provides information about an android app from {@link PackageManager}.
*
* Visible for testing.
*
* @hide
*/
public class AndroidPackageInfoFetcher {
/**
* The name of the metadata tag in AndroidManifest.xml that stores the associated asset array
* ID. The metadata tag should use the android:resource attribute to point to an array resource
* that contains the associated assets.
*/
private static final String ASSOCIATED_ASSETS_KEY = "associated_assets";
private Context mContext;
public AndroidPackageInfoFetcher(Context context) {
mContext = context;
}
/**
* Returns the Sha-256 fingerprints of all certificates from the specified package as a list of
* upper case HEX Strings with bytes separated by colons. Given an app {@link
* android.content.pm.Signature}, the fingerprint can be computed as {@link
* Utils#computeNormalizedSha256Fingerprint} {@code(signature.toByteArray())}.
*
* <p>Given a signed APK, Java 7's commandline keytool can compute the fingerprint using: {@code
* keytool -list -printcert -jarfile signed_app.apk}
*
* <p>Example: "10:39:38:EE:45:37:E5:9E:8E:E7:92:F6:54:50:4F:B8:34:6F:C6:B3:46:D0:BB:C4:41:5F:C3:39:FC:FC:8E:C1"
*
* @throws NameNotFoundException if an app with packageName is not installed on the device.
*/
public List<String> getCertFingerprints(String packageName) throws NameNotFoundException {
return Utils.getCertFingerprintsFromPackageManager(packageName, mContext);
}
/**
* Returns all statements that the specified package makes in its AndroidManifest.xml.
*
* @throws NameNotFoundException if the app is not installed on the device.
*/
public List<String> getStatements(String packageName) throws NameNotFoundException {
PackageInfo packageInfo = mContext.getPackageManager().getPackageInfo(
packageName, PackageManager.GET_META_DATA);
ApplicationInfo appInfo = packageInfo.applicationInfo;
if (appInfo.metaData == null) {
return Collections.<String>emptyList();
}
int tokenResourceId = appInfo.metaData.getInt(ASSOCIATED_ASSETS_KEY);
if (tokenResourceId == 0) {
return Collections.<String>emptyList();
}
try {
return Arrays.asList(
mContext.getPackageManager().getResourcesForApplication(packageName)
.getStringArray(tokenResourceId));
} catch (NotFoundException e) {
return Collections.<String>emptyList();
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Factory to create asset from JSON string.
*/
/* package private */ final class AssetFactory {
private static final String FIELD_NOT_STRING_FORMAT_STRING = "Expected %s to be string.";
private AssetFactory() {}
/**
* Creates a new Asset object from its JSON string representation.
*
* @throws AssociationServiceException if the assetJson is not well formatted.
*/
public static AbstractAsset create(String assetJson) throws AssociationServiceException {
try {
return create(new JSONObject(assetJson));
} catch (JSONException e) {
throw new AssociationServiceException(
"Input is not a well formatted asset descriptor.");
}
}
/**
* Checks that the input is a valid asset with purposes.
*
* @throws AssociationServiceException if the asset is not well formatted.
*/
private static AbstractAsset create(JSONObject asset)
throws AssociationServiceException {
String namespace = asset.optString(Utils.NAMESPACE_FIELD, null);
if (namespace == null) {
throw new AssociationServiceException(String.format(
FIELD_NOT_STRING_FORMAT_STRING, Utils.NAMESPACE_FIELD));
}
if (namespace.equals(Utils.NAMESPACE_WEB)) {
return WebAsset.create(asset);
} else if (namespace.equals(Utils.NAMESPACE_ANDROID_APP)) {
return AndroidAppAsset.create(asset);
} else {
throw new AssociationServiceException("Namespace " + namespace + " is not supported.");
}
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import android.util.JsonWriter;
import java.io.IOException;
import java.io.StringWriter;
import java.util.List;
import java.util.Locale;
/**
* Creates a Json string where the order of the fields can be specified.
*/
/* package private */ final class AssetJsonWriter {
private StringWriter mStringWriter = new StringWriter();
private JsonWriter mWriter;
private boolean mClosed = false;
public AssetJsonWriter() {
mWriter = new JsonWriter(mStringWriter);
try {
mWriter.beginObject();
} catch (IOException e) {
throw new AssertionError("Unreachable exception.");
}
}
/**
* Appends a field to the output, putting both the key and value in lowercase. Null values are
* not written.
*/
public void writeFieldLower(String key, String value) {
if (mClosed) {
throw new IllegalArgumentException(
"Cannot write to an object that has already been closed.");
}
if (value != null) {
try {
mWriter.name(key.toLowerCase(Locale.US));
mWriter.value(value.toLowerCase(Locale.US));
} catch (IOException e) {
throw new AssertionError("Unreachable exception.");
}
}
}
/**
* Appends an array to the output, putting both the key and values in lowercase. If {@code
* values} is null, this field will not be written. Individual values in the list must not be
* null.
*/
public void writeArrayUpper(String key, List<String> values) {
if (mClosed) {
throw new IllegalArgumentException(
"Cannot write to an object that has already been closed.");
}
if (values != null) {
try {
mWriter.name(key.toLowerCase(Locale.US));
mWriter.beginArray();
for (String value : values) {
mWriter.value(value.toUpperCase(Locale.US));
}
mWriter.endArray();
} catch (IOException e) {
throw new AssertionError("Unreachable exception.");
}
}
}
/**
* Returns the string representation of the constructed json. After calling this method, {@link
* #writeFieldLower} can no longer be called.
*/
public String closeAndGetString() {
if (!mClosed) {
try {
mWriter.endObject();
} catch (IOException e) {
throw new AssertionError("Unreachable exception.");
}
mClosed = true;
}
return mStringWriter.toString();
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Factory to create asset matcher from JSON string.
*/
/* package private */ final class AssetMatcherFactory {
private static final String FIELD_NOT_STRING_FORMAT_STRING = "Expected %s to be string.";
private static final String NAMESPACE_NOT_SUPPORTED_STRING = "Namespace %s is not supported.";
public static AbstractAssetMatcher create(String query) throws AssociationServiceException,
JSONException {
JSONObject queryObject = new JSONObject(query);
String namespace = queryObject.optString(Utils.NAMESPACE_FIELD, null);
if (namespace == null) {
throw new AssociationServiceException(String.format(
FIELD_NOT_STRING_FORMAT_STRING, Utils.NAMESPACE_FIELD));
}
if (namespace.equals(Utils.NAMESPACE_WEB)) {
return new WebAssetMatcher(WebAsset.create(queryObject));
} else if (namespace.equals(Utils.NAMESPACE_ANDROID_APP)) {
return new AndroidAppAssetMatcher(AndroidAppAsset.create(queryObject));
} else {
throw new AssociationServiceException(
String.format(NAMESPACE_NOT_SUPPORTED_STRING, namespace));
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
/**
* Thrown when an error occurs in the Association Service.
*/
public class AssociationServiceException extends Exception {
public AssociationServiceException(String msg) {
super(msg);
}
public AssociationServiceException(String msg, Exception e) {
super(msg, e);
}
public AssociationServiceException(Exception e) {
super(e);
}
}

View File

@@ -0,0 +1,204 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import android.content.pm.PackageManager.NameNotFoundException;
import android.util.Log;
import org.json.JSONException;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* An implementation of {@link AbstractStatementRetriever} that directly retrieves statements from
* the asset.
*/
/* package private */ final class DirectStatementRetriever extends AbstractStatementRetriever {
private static final long DO_NOT_CACHE_RESULT = 0L;
private static final int HTTP_CONNECTION_TIMEOUT_MILLIS = 5000;
private static final long HTTP_CONTENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
private static final int MAX_INCLUDE_LEVEL = 1;
private static final String WELL_KNOWN_STATEMENT_PATH = "/.well-known/associations.json";
private final URLFetcher mUrlFetcher;
private final AndroidPackageInfoFetcher mAndroidFetcher;
/**
* An immutable value type representing the retrieved statements and the expiration date.
*/
public static class Result implements AbstractStatementRetriever.Result {
private final List<Statement> mStatements;
private final Long mExpireMillis;
@Override
public List<Statement> getStatements() {
return mStatements;
}
@Override
public long getExpireMillis() {
return mExpireMillis;
}
private Result(List<Statement> statements, Long expireMillis) {
mStatements = statements;
mExpireMillis = expireMillis;
}
public static Result create(List<Statement> statements, Long expireMillis) {
return new Result(statements, expireMillis);
}
@Override
public String toString() {
StringBuilder result = new StringBuilder();
result.append("Result: ");
result.append(mStatements.toString());
result.append(", mExpireMillis=");
result.append(mExpireMillis);
return result.toString();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Result result = (Result) o;
if (!mExpireMillis.equals(result.mExpireMillis)) {
return false;
}
if (!mStatements.equals(result.mStatements)) {
return false;
}
return true;
}
@Override
public int hashCode() {
int result = mStatements.hashCode();
result = 31 * result + mExpireMillis.hashCode();
return result;
}
}
public DirectStatementRetriever(URLFetcher urlFetcher,
AndroidPackageInfoFetcher androidFetcher) {
this.mUrlFetcher = urlFetcher;
this.mAndroidFetcher = androidFetcher;
}
@Override
public Result retrieveStatements(AbstractAsset source) throws AssociationServiceException {
if (source instanceof AndroidAppAsset) {
return retrieveFromAndroid((AndroidAppAsset) source);
} else if (source instanceof WebAsset) {
return retrieveFromWeb((WebAsset) source);
} else {
throw new AssociationServiceException("Namespace is not supported.");
}
}
private String computeAssociationJsonUrl(WebAsset asset) {
try {
return new URL(asset.getScheme(), asset.getDomain(), asset.getPort(),
WELL_KNOWN_STATEMENT_PATH)
.toExternalForm();
} catch (MalformedURLException e) {
throw new AssertionError("Invalid domain name in database.");
}
}
private Result retrieveStatementFromUrl(String url, int maxIncludeLevel, AbstractAsset source)
throws AssociationServiceException {
List<Statement> statements = new ArrayList<Statement>();
if (maxIncludeLevel < 0) {
return Result.create(statements, DO_NOT_CACHE_RESULT);
}
WebContent webContent;
try {
webContent = mUrlFetcher.getWebContentFromUrl(new URL(url),
HTTP_CONTENT_SIZE_LIMIT_IN_BYTES, HTTP_CONNECTION_TIMEOUT_MILLIS);
} catch (IOException e) {
return Result.create(statements, DO_NOT_CACHE_RESULT);
}
try {
ParsedStatement result = StatementParser
.parseStatementList(webContent.getContent(), source);
statements.addAll(result.getStatements());
for (String delegate : result.getDelegates()) {
statements.addAll(
retrieveStatementFromUrl(delegate, maxIncludeLevel - 1, source)
.getStatements());
}
return Result.create(statements, webContent.getExpireTimeMillis());
} catch (JSONException e) {
return Result.create(statements, DO_NOT_CACHE_RESULT);
}
}
private Result retrieveFromWeb(WebAsset asset)
throws AssociationServiceException {
return retrieveStatementFromUrl(computeAssociationJsonUrl(asset), MAX_INCLUDE_LEVEL, asset);
}
private Result retrieveFromAndroid(AndroidAppAsset asset) throws AssociationServiceException {
try {
List<String> delegates = new ArrayList<String>();
List<Statement> statements = new ArrayList<Statement>();
List<String> certFps = mAndroidFetcher.getCertFingerprints(asset.getPackageName());
if (!Utils.hasCommonString(certFps, asset.getCertFingerprints())) {
throw new AssociationServiceException(
"Specified certs don't match the installed app.");
}
AndroidAppAsset actualSource = AndroidAppAsset.create(asset.getPackageName(), certFps);
for (String statementJson : mAndroidFetcher.getStatements(asset.getPackageName())) {
ParsedStatement result =
StatementParser.parseStatement(statementJson, actualSource);
statements.addAll(result.getStatements());
delegates.addAll(result.getDelegates());
}
for (String delegate : delegates) {
statements.addAll(retrieveStatementFromUrl(delegate, MAX_INCLUDE_LEVEL,
actualSource).getStatements());
}
return Result.create(statements, DO_NOT_CACHE_RESULT);
} catch (JSONException | NameNotFoundException e) {
Log.w(DirectStatementRetriever.class.getSimpleName(), e);
return Result.create(Collections.<Statement>emptyList(), DO_NOT_CACHE_RESULT);
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import java.util.List;
/**
* A class that stores a list of statement and/or a list of delegate url.
*/
/* package private */ final class ParsedStatement {
private final List<Statement> mStatements;
private final List<String> mDelegates;
public ParsedStatement(List<Statement> statements, List<String> delegates) {
this.mStatements = statements;
this.mDelegates = delegates;
}
public List<Statement> getStatements() {
return mStatements;
}
public List<String> getDelegates() {
return mDelegates;
}
}

View File

@@ -0,0 +1,143 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import android.annotation.NonNull;
import java.util.regex.Pattern;
/**
* An immutable value type representing a statement relation with "kind" and "detail".
*
* <p> The set of kinds is enumerated by the API: <ul> <li> <b>delegate_permission</b>: The detail
* field specifies which permission to delegate. A statement involving this relation does not
* constitute a requirement to do the delegation, just a permission to do so. </ul>
*
* <p> We may add other kinds in the future.
*
* <p> The detail field is a lowercase alphanumeric string with underscores and periods allowed
* (matching the regex [a-z0-9_.]+), but otherwise unstructured. It is also possible to specify '*'
* (the wildcard character) as the detail if the relation applies to any detail in the specified
* kind.
*/
public final class Relation {
private static final Pattern KIND_PATTERN = Pattern.compile("^[a-z0-9_.]+$");
private static final Pattern DETAIL_PATTERN = Pattern.compile("^([a-z0-9_.]+|[*])$");
private static final String MATCH_ALL_DETAILS = "*";
private final String mKind;
private final String mDetail;
private Relation(String kind, String detail) {
mKind = kind;
mDetail = detail;
}
/**
* Returns the relation's kind.
*/
@NonNull
public String getKind() {
return mKind;
}
/**
* Returns the relation's detail.
*/
@NonNull
public String getDetail() {
return mDetail;
}
/**
* Creates a new Relation object for the specified {@code kind} and {@code detail}.
*
* @throws AssociationServiceException if {@code kind} or {@code detail} is not well formatted.
*/
public static Relation create(@NonNull String kind, @NonNull String detail)
throws AssociationServiceException {
if (!KIND_PATTERN.matcher(kind).matches() || !DETAIL_PATTERN.matcher(detail).matches()) {
throw new AssociationServiceException("Relation not well formatted.");
}
return new Relation(kind, detail);
}
/**
* Creates a new Relation object from its string representation.
*
* @throws AssociationServiceException if the relation is not well formatted.
*/
public static Relation create(@NonNull String relation) throws AssociationServiceException {
String[] r = relation.split("/", 2);
if (r.length != 2) {
throw new AssociationServiceException("Relation not well formatted.");
}
return create(r[0], r[1]);
}
/**
* Returns true if {@code relation} has the same kind and detail. If {@code
* relation.getDetail()} is wildcard (*) then returns true if the kind is the same.
*/
public boolean matches(Relation relation) {
return getKind().equals(relation.getKind()) && (getDetail().equals(MATCH_ALL_DETAILS)
|| getDetail().equals(relation.getDetail()));
}
/**
* Returns a string representation of this relation.
*/
@Override
public String toString() {
StringBuilder relation = new StringBuilder();
relation.append(getKind());
relation.append("/");
relation.append(getDetail());
return relation.toString();
}
// equals() and hashCode() are generated by Android Studio.
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Relation relation = (Relation) o;
if (mDetail != null ? !mDetail.equals(relation.mDetail) : relation.mDetail != null) {
return false;
}
if (mKind != null ? !mKind.equals(relation.mKind) : relation.mKind != null) {
return false;
}
return true;
}
@Override
public int hashCode() {
int result = mKind != null ? mKind.hashCode() : 0;
result = 31 * result + (mDetail != null ? mDetail.hashCode() : 0);
return result;
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import android.annotation.NonNull;
/**
* An immutable value type representing a statement, consisting of a source, target, and relation.
* This reflects an assertion that the relation holds for the source, target pair. For example, if a
* web site has the following in its associations.json file:
*
* <pre>
* {
* "relation": ["delegate_permission/common.handle_all_urls"],
* "target" : {"namespace": "android_app", "package_name": "com.example.app",
* "sha256_cert_fingerprints": ["00:11:22:33"] }
* }
* </pre>
*
* Then invoking {@link AbstractStatementRetriever#retrieveStatements(AbstractAsset)} will return a
* {@link Statement} with {@link #getSource} equal to the input parameter, {@link #getRelation}
* equal to
*
* <pre>Relation.create("delegate_permission", "common.get_login_creds");</pre>
*
* and with {@link #getTarget} equal to
*
* <pre>AbstractAsset.create("{\"namespace\" : \"android_app\","
* + "\"package_name\": \"com.example.app\"}"
* + "\"sha256_cert_fingerprints\": \"[\"00:11:22:33\"]\"}");
* </pre>
*/
public final class Statement {
private final AbstractAsset mTarget;
private final Relation mRelation;
private final AbstractAsset mSource;
private Statement(AbstractAsset source, AbstractAsset target, Relation relation) {
mSource = source;
mTarget = target;
mRelation = relation;
}
/**
* Returns the source asset of the statement.
*/
@NonNull
public AbstractAsset getSource() {
return mSource;
}
/**
* Returns the target asset of the statement.
*/
@NonNull
public AbstractAsset getTarget() {
return mTarget;
}
/**
* Returns the relation of the statement.
*/
@NonNull
public Relation getRelation() {
return mRelation;
}
/**
* Creates a new Statement object for the specified target asset and relation. For example:
* <pre>
* Asset asset = Asset.Factory.create(
* "{\"namespace\" : \"web\",\"site\": \"https://www.test.com\"}");
* Relation relation = Relation.create("delegate_permission", "common.get_login_creds");
* Statement statement = Statement.create(asset, relation);
* </pre>
*/
public static Statement create(@NonNull AbstractAsset source, @NonNull AbstractAsset target,
@NonNull Relation relation) {
return new Statement(source, target, relation);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Statement statement = (Statement) o;
if (!mRelation.equals(statement.mRelation)) {
return false;
}
if (!mTarget.equals(statement.mTarget)) {
return false;
}
if (!mSource.equals(statement.mSource)) {
return false;
}
return true;
}
@Override
public int hashCode() {
int result = mTarget.hashCode();
result = 31 * result + mRelation.hashCode();
result = 31 * result + mSource.hashCode();
return result;
}
@Override
public String toString() {
StringBuilder statement = new StringBuilder();
statement.append("Statement: ");
statement.append(mSource);
statement.append(", ");
statement.append(mTarget);
statement.append(", ");
statement.append(mRelation);
return statement.toString();
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
/**
* Utility class that parses JSON-formatted statements.
*/
/* package private */ final class StatementParser {
/**
* Parses a JSON array of statements.
*/
static ParsedStatement parseStatementList(String statementList, AbstractAsset source)
throws JSONException, AssociationServiceException {
List<Statement> statements = new ArrayList<Statement>();
List<String> delegates = new ArrayList<String>();
JSONArray statementsJson = new JSONArray(statementList);
for (int i = 0; i < statementsJson.length(); i++) {
ParsedStatement result = parseStatement(statementsJson.getString(i), source);
statements.addAll(result.getStatements());
delegates.addAll(result.getDelegates());
}
return new ParsedStatement(statements, delegates);
}
/**
* Parses a single JSON statement.
*/
static ParsedStatement parseStatement(String statementString, AbstractAsset source)
throws JSONException, AssociationServiceException {
List<Statement> statements = new ArrayList<Statement>();
List<String> delegates = new ArrayList<String>();
JSONObject statement = new JSONObject(statementString);
if (statement.optString(Utils.DELEGATE_FIELD_DELEGATE, null) != null) {
delegates.add(statement.optString(Utils.DELEGATE_FIELD_DELEGATE));
} else {
AbstractAsset target = AssetFactory
.create(statement.getString(Utils.ASSET_DESCRIPTOR_FIELD_TARGET));
JSONArray relations = statement.getJSONArray(
Utils.ASSET_DESCRIPTOR_FIELD_RELATION);
for (int i = 0; i < relations.length(); i++) {
statements.add(Statement
.create(source, target, Relation.create(relations.getString(i))));
}
}
return new ParsedStatement(statements, delegates);
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import com.android.volley.Cache;
import com.android.volley.NetworkResponse;
import com.android.volley.toolbox.HttpHeaderParser;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Helper class for fetching HTTP or HTTPS URL.
*
* Visible for testing.
*
* @hide
*/
public class URLFetcher {
private static final long DO_NOT_CACHE_RESULT = 0L;
private static final int INPUT_BUFFER_SIZE_IN_BYTES = 1024;
/**
* Fetches the specified url and returns the content and ttl.
*
* @throws IOException if it can't retrieve the content due to a network problem.
* @throws AssociationServiceException if the URL scheme is not http or https or the content
* length exceeds {code fileSizeLimit}.
*/
public WebContent getWebContentFromUrl(URL url, long fileSizeLimit, int connectionTimeoutMillis)
throws AssociationServiceException, IOException {
final String scheme = url.getProtocol().toLowerCase(Locale.US);
if (!scheme.equals("http") && !scheme.equals("https")) {
throw new IllegalArgumentException("The url protocol should be on http or https.");
}
HttpURLConnection connection;
connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(true);
connection.setConnectTimeout(connectionTimeoutMillis);
connection.setReadTimeout(connectionTimeoutMillis);
connection.setUseCaches(true);
connection.addRequestProperty("Cache-Control", "max-stale=60");
if (connection.getContentLength() > fileSizeLimit) {
throw new AssociationServiceException("The content size of the url is larger than "
+ fileSizeLimit);
}
Long expireTimeMillis = getExpirationTimeMillisFromHTTPHeader(connection.getHeaderFields());
try {
return new WebContent(inputStreamToString(
connection.getInputStream(), connection.getContentLength(), fileSizeLimit),
expireTimeMillis);
} finally {
connection.disconnect();
}
}
/**
* Visible for testing.
* @hide
*/
public static String inputStreamToString(InputStream inputStream, int length, long sizeLimit)
throws IOException, AssociationServiceException {
if (length < 0) {
length = 0;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream(length);
BufferedInputStream bis = new BufferedInputStream(inputStream);
byte[] buffer = new byte[INPUT_BUFFER_SIZE_IN_BYTES];
int len = 0;
while ((len = bis.read(buffer)) != -1) {
baos.write(buffer, 0, len);
if (baos.size() > sizeLimit) {
throw new AssociationServiceException("The content size of the url is larger than "
+ sizeLimit);
}
}
return baos.toString("UTF-8");
}
/**
* Parses the HTTP headers to compute the ttl.
*
* @param headers a map that map the header key to the header values. Can be null.
* @return the ttl in millisecond or null if the ttl is not specified in the header.
*/
private Long getExpirationTimeMillisFromHTTPHeader(Map<String, List<String>> headers) {
if (headers == null) {
return null;
}
Map<String, String> joinedHeaders = joinHttpHeaders(headers);
NetworkResponse response = new NetworkResponse(null, joinedHeaders);
Cache.Entry cachePolicy = HttpHeaderParser.parseCacheHeaders(response);
if (cachePolicy == null) {
// Cache is disabled, set the expire time to 0.
return DO_NOT_CACHE_RESULT;
} else if (cachePolicy.ttl == 0) {
// Cache policy is not specified, set the expire time to 0.
return DO_NOT_CACHE_RESULT;
} else {
// cachePolicy.ttl is actually the expire timestamp in millisecond.
return cachePolicy.ttl;
}
}
/**
* Converts an HTTP header map of the format provided by {@linkHttpUrlConnection} to a map of
* the format accepted by {@link HttpHeaderParser}. It does this by joining all the entries for
* a given header key with ", ".
*/
private Map<String, String> joinHttpHeaders(Map<String, List<String>> headers) {
Map<String, String> joinedHeaders = new HashMap<String, String>();
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
List<String> values = entry.getValue();
if (values.size() == 1) {
joinedHeaders.put(entry.getKey(), values.get(0));
} else {
joinedHeaders.put(entry.getKey(), Utils.joinStrings(", ", values));
}
}
return joinedHeaders;
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
/**
* Utility library for computing certificate fingerprints. Also includes fields name used by
* Statement JSON string.
*/
public final class Utils {
private Utils() {}
/**
* Field name for namespace.
*/
public static final String NAMESPACE_FIELD = "namespace";
/**
* Supported asset namespaces.
*/
public static final String NAMESPACE_WEB = "web";
public static final String NAMESPACE_ANDROID_APP = "android_app";
/**
* Field names in a web asset descriptor.
*/
public static final String WEB_ASSET_FIELD_SITE = "site";
/**
* Field names in a Android app asset descriptor.
*/
public static final String ANDROID_APP_ASSET_FIELD_PACKAGE_NAME = "package_name";
public static final String ANDROID_APP_ASSET_FIELD_CERT_FPS = "sha256_cert_fingerprints";
/**
* Field names in a statement.
*/
public static final String ASSET_DESCRIPTOR_FIELD_RELATION = "relation";
public static final String ASSET_DESCRIPTOR_FIELD_TARGET = "target";
public static final String DELEGATE_FIELD_DELEGATE = "delegate";
private static final char[] HEX_DIGITS = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'A', 'B', 'C', 'D', 'E', 'F' };
/**
* Joins a list of strings, by placing separator between each string. For example,
* {@code joinStrings("; ", Arrays.asList(new String[]{"a", "b", "c"}))} returns
* "{@code a; b; c}".
*/
public static String joinStrings(String separator, List<String> strings) {
switch(strings.size()) {
case 0:
return "";
case 1:
return strings.get(0);
default:
StringBuilder joiner = new StringBuilder();
boolean first = true;
for (String field : strings) {
if (first) {
first = false;
} else {
joiner.append(separator);
}
joiner.append(field);
}
return joiner.toString();
}
}
/**
* Returns the normalized sha-256 fingerprints of a given package according to the Android
* package manager.
*/
public static List<String> getCertFingerprintsFromPackageManager(String packageName,
Context context) throws NameNotFoundException {
Signature[] signatures = context.getPackageManager().getPackageInfo(packageName,
PackageManager.GET_SIGNATURES).signatures;
ArrayList<String> result = new ArrayList<String>(signatures.length);
for (Signature sig : signatures) {
result.add(computeNormalizedSha256Fingerprint(sig.toByteArray()));
}
return result;
}
/**
* Computes the hash of the byte array using the specified algorithm, returning a hex string
* with a colon between each byte.
*/
public static String computeNormalizedSha256Fingerprint(byte[] signature) {
MessageDigest digester;
try {
digester = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new AssertionError("No SHA-256 implementation found.");
}
digester.update(signature);
return byteArrayToHexString(digester.digest());
}
/**
* Returns true if there is at least one common string between the two lists of string.
*/
public static boolean hasCommonString(List<String> list1, List<String> list2) {
HashSet<String> set2 = new HashSet<>(list2);
for (String string : list1) {
if (set2.contains(string)) {
return true;
}
}
return false;
}
/**
* Converts the byte array to an lowercase hexadecimal digits String with a colon character (:)
* between each byte.
*/
private static String byteArrayToHexString(byte[] array) {
if (array.length == 0) {
return "";
}
char[] buf = new char[array.length * 3 - 1];
int bufIndex = 0;
for (int i = 0; i < array.length; i++) {
byte b = array[i];
if (i > 0) {
buf[bufIndex++] = ':';
}
buf[bufIndex++] = HEX_DIGITS[(b >>> 4) & 0x0F];
buf[bufIndex++] = HEX_DIGITS[b & 0x0F];
}
return new String(buf);
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
import org.json.JSONObject;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Locale;
/**
* Immutable value type that names a web asset.
*
* <p>A web asset can be named by its protocol, domain, and port using this JSON string:
* { "namespace": "web",
* "site": "[protocol]://[fully-qualified domain]{:[optional port]}" }
*
* <p>For example, a website hosted on a https server at www.test.com can be named using
* { "namespace": "web",
* "site": "https://www.test.com" }
*
* <p>The only protocol supported now are https and http. If the optional port is not specified,
* the default for each protocol will be used (i.e. 80 for http and 443 for https).
*/
/* package private */ final class WebAsset extends AbstractAsset {
private static final String MISSING_FIELD_FORMAT_STRING = "Expected %s to be set.";
private final URL mUrl;
private WebAsset(URL url) {
int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
try {
mUrl = new URL(url.getProtocol().toLowerCase(), url.getHost().toLowerCase(), port, "");
} catch (MalformedURLException e) {
throw new AssertionError(
"Url should always be validated before calling the constructor.");
}
}
public String getDomain() {
return mUrl.getHost();
}
public String getPath() {
return mUrl.getPath();
}
public String getScheme() {
return mUrl.getProtocol();
}
public int getPort() {
return mUrl.getPort();
}
@Override
public String toJson() {
AssetJsonWriter writer = new AssetJsonWriter();
writer.writeFieldLower(Utils.NAMESPACE_FIELD, Utils.NAMESPACE_WEB);
writer.writeFieldLower(Utils.WEB_ASSET_FIELD_SITE, mUrl.toExternalForm());
return writer.closeAndGetString();
}
@Override
public String toString() {
StringBuilder asset = new StringBuilder();
asset.append("WebAsset: ");
asset.append(toJson());
return asset.toString();
}
@Override
public boolean equals(Object o) {
if (!(o instanceof WebAsset)) {
return false;
}
return ((WebAsset) o).toJson().equals(toJson());
}
@Override
public int hashCode() {
return toJson().hashCode();
}
@Override
public int lookupKey() {
return toJson().hashCode();
}
/**
* Checks that the input is a valid web asset.
*
* @throws AssociationServiceException if the asset is not well formatted.
*/
protected static WebAsset create(JSONObject asset)
throws AssociationServiceException {
if (asset.optString(Utils.WEB_ASSET_FIELD_SITE).equals("")) {
throw new AssociationServiceException(String.format(MISSING_FIELD_FORMAT_STRING,
Utils.WEB_ASSET_FIELD_SITE));
}
URL url;
try {
url = new URL(asset.optString(Utils.WEB_ASSET_FIELD_SITE));
} catch (MalformedURLException e) {
throw new AssociationServiceException("Url is not well formatted.", e);
}
String scheme = url.getProtocol().toLowerCase(Locale.US);
if (!scheme.equals("https") && !scheme.equals("http")) {
throw new AssociationServiceException("Expected scheme to be http or https.");
}
if (url.getUserInfo() != null) {
throw new AssociationServiceException("The url should not contain user info.");
}
String path = url.getFile(); // This is url.getPath() + url.getQuery().
if (!path.equals("/") && !path.equals("")) {
throw new AssociationServiceException(
"Site should only have scheme, domain, and port.");
}
return new WebAsset(url);
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
/**
* Match assets that have the same 'site' field.
*/
/* package private */ final class WebAssetMatcher extends AbstractAssetMatcher {
private final WebAsset mQuery;
public WebAssetMatcher(WebAsset query) {
mQuery = query;
}
@Override
public boolean matches(AbstractAsset asset) {
if (asset instanceof WebAsset) {
WebAsset webAsset = (WebAsset) asset;
return webAsset.toJson().equals(mQuery.toJson());
}
return false;
}
@Override
public int getMatchedLookupKey() {
return mQuery.lookupKey();
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2015 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 com.android.statementservice.retriever;
/**
* An immutable value type representing the response from a web server.
*
* Visible for testing.
*
* @hide
*/
public final class WebContent {
private final String mContent;
private final Long mExpireTimeMillis;
public WebContent(String content, Long expireTimeMillis) {
mContent = content;
mExpireTimeMillis = expireTimeMillis;
}
/**
* Returns the expiration time of the content as specified in the HTTP header.
*/
public Long getExpireTimeMillis() {
return mExpireTimeMillis;
}
/**
* Returns content of the HTTP message body.
*/
public String getContent() {
return mContent;
}
}