diff options
27 files changed, 2592 insertions, 0 deletions
diff --git a/packages/StatementService/Android.mk b/packages/StatementService/Android.mk new file mode 100644 index 0000000..f0adb1c --- /dev/null +++ b/packages/StatementService/Android.mk @@ -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) diff --git a/packages/StatementService/AndroidManifest.xml b/packages/StatementService/AndroidManifest.xml new file mode 100644 index 0000000..3ee453b --- /dev/null +++ b/packages/StatementService/AndroidManifest.xml @@ -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> diff --git a/packages/StatementService/proguard.flags b/packages/StatementService/proguard.flags new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/StatementService/proguard.flags diff --git a/packages/StatementService/res/values/strings.xml b/packages/StatementService/res/values/strings.xml new file mode 100644 index 0000000..df6d80b --- /dev/null +++ b/packages/StatementService/res/values/strings.xml @@ -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> 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; + } +} |