This commit adds a verifier that verifies a host delegates permission for an app to handle Url for the host using the Statement protocol. - Implements the Statement protocol -- The protocol defines a file format that represents statements. -- The protocol defines where each asset type should put their statement declaration. For web asset, the statement file should be hosted at <scheme>://<host>:<port>/.well-known/associations.json. - Implements IntentFilterVerificationReceiver, an interface between StatementService and PackageManager. PackageManager will send a broadcast with action Intent.ACTION_INTENT_FILTER_NEEDS_VERIFICATION. The service will process the request and returns the results by calling PackageManager.verifyIntentFilter(). To verify an IntentFilter like this defined in Android app com.test.app <intent-filter> <data android:scheme="https" /> <data android:host="www.test.com" /> <data android:pathPattern=".*"/> </intent-filter> The service will try to retrieve the statement file from https://www.test.com:443/.well-known/associations.json and try to find a JSON object equivalent to {'relation': ['delegate_permission/common.handle_all_urls'], 'target': {'namespace': 'android_app', 'package_name': 'com.test.app', 'sha256_cert_fingerprints': [APP_CERT_FP]}} The entry should have the correct relation, package name, and certificate sha256 fingerprint. Because this implementation will send a HTTP request for each host specified in the intent-filter in AndroidManifest.xml, to avoid overwhelming the network at app install time, we limit the maximum number of hosts we will verify for a single app to 10. Any app with more than 10 hosts in the autoVerify=true intent-filter won't be auto verified. Change-Id: I787c9d176e4110aa441eb5fe4fa9651a071c6610
291 lines
11 KiB
Java
291 lines
11 KiB
Java
/*
|
|
* 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;
|
|
}
|
|
}
|
|
}
|