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
diff --git a/packages/StatementService/src/com/android/statementservice/DirectStatementService.java b/packages/StatementService/src/com/android/statementservice/DirectStatementService.java
new file mode 100644
index 0000000..449738e
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/DirectStatementService.java
@@ -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;
+ }
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/ExceptionLoggingFutureTask.java b/packages/StatementService/src/com/android/statementservice/ExceptionLoggingFutureTask.java
new file mode 100644
index 0000000..20c7f97
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/ExceptionLoggingFutureTask.java
@@ -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);
+ }
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/IntentFilterVerificationReceiver.java b/packages/StatementService/src/com/android/statementservice/IntentFilterVerificationReceiver.java
new file mode 100644
index 0000000..712347a
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/IntentFilterVerificationReceiver.java
@@ -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);
+ }
+ }
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AbstractAsset.java b/packages/StatementService/src/com/android/statementservice/retriever/AbstractAsset.java
new file mode 100644
index 0000000..e71cf54
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AbstractAsset.java
@@ -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);
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AbstractAssetMatcher.java b/packages/StatementService/src/com/android/statementservice/retriever/AbstractAssetMatcher.java
new file mode 100644
index 0000000..c35553f
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AbstractAssetMatcher.java
@@ -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);
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AbstractStatementRetriever.java b/packages/StatementService/src/com/android/statementservice/retriever/AbstractStatementRetriever.java
new file mode 100644
index 0000000..fb30bc1
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AbstractStatementRetriever.java
@@ -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));
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AndroidAppAsset.java b/packages/StatementService/src/com/android/statementservice/retriever/AndroidAppAsset.java
new file mode 100644
index 0000000..0c96038
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AndroidAppAsset.java
@@ -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;
+ }
+
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AndroidAppAssetMatcher.java b/packages/StatementService/src/com/android/statementservice/retriever/AndroidAppAssetMatcher.java
new file mode 100644
index 0000000..8a9d838
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AndroidAppAssetMatcher.java
@@ -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();
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AndroidPackageInfoFetcher.java b/packages/StatementService/src/com/android/statementservice/retriever/AndroidPackageInfoFetcher.java
new file mode 100644
index 0000000..1000c4c
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AndroidPackageInfoFetcher.java
@@ -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();
+ }
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AssetFactory.java b/packages/StatementService/src/com/android/statementservice/retriever/AssetFactory.java
new file mode 100644
index 0000000..762365e
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AssetFactory.java
@@ -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.");
+ }
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AssetJsonWriter.java b/packages/StatementService/src/com/android/statementservice/retriever/AssetJsonWriter.java
new file mode 100644
index 0000000..080e45a
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AssetJsonWriter.java
@@ -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();
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AssetMatcherFactory.java b/packages/StatementService/src/com/android/statementservice/retriever/AssetMatcherFactory.java
new file mode 100644
index 0000000..1a50757
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AssetMatcherFactory.java
@@ -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));
+ }
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/AssociationServiceException.java b/packages/StatementService/src/com/android/statementservice/retriever/AssociationServiceException.java
new file mode 100644
index 0000000..d6e49c2
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/AssociationServiceException.java
@@ -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);
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/DirectStatementRetriever.java b/packages/StatementService/src/com/android/statementservice/retriever/DirectStatementRetriever.java
new file mode 100644
index 0000000..3ad71c4
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/DirectStatementRetriever.java
@@ -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);
+ }
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/ParsedStatement.java b/packages/StatementService/src/com/android/statementservice/retriever/ParsedStatement.java
new file mode 100644
index 0000000..9446e66
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/ParsedStatement.java
@@ -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;
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/Relation.java b/packages/StatementService/src/com/android/statementservice/retriever/Relation.java
new file mode 100644
index 0000000..91218c6
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/Relation.java
@@ -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;
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/Statement.java b/packages/StatementService/src/com/android/statementservice/retriever/Statement.java
new file mode 100644
index 0000000..f83edaf
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/Statement.java
@@ -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();
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/StatementParser.java b/packages/StatementService/src/com/android/statementservice/retriever/StatementParser.java
new file mode 100644
index 0000000..bcd91bd
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/StatementParser.java
@@ -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);
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/URLFetcher.java b/packages/StatementService/src/com/android/statementservice/retriever/URLFetcher.java
new file mode 100644
index 0000000..4828ff9
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/URLFetcher.java
@@ -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;
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/Utils.java b/packages/StatementService/src/com/android/statementservice/retriever/Utils.java
new file mode 100644
index 0000000..44af864
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/Utils.java
@@ -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);
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/WebAsset.java b/packages/StatementService/src/com/android/statementservice/retriever/WebAsset.java
new file mode 100644
index 0000000..ca9e62d
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/WebAsset.java
@@ -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);
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/WebAssetMatcher.java b/packages/StatementService/src/com/android/statementservice/retriever/WebAssetMatcher.java
new file mode 100644
index 0000000..8a1078b
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/WebAssetMatcher.java
@@ -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();
+ }
+}
diff --git a/packages/StatementService/src/com/android/statementservice/retriever/WebContent.java b/packages/StatementService/src/com/android/statementservice/retriever/WebContent.java
new file mode 100644
index 0000000..86a635c
--- /dev/null
+++ b/packages/StatementService/src/com/android/statementservice/retriever/WebContent.java
@@ -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;
+ }
+}