Merge "DSU to support GSI key revocation list"

This commit is contained in:
Yo Chiang
2020-02-05 19:01:54 +00:00
committed by Gerrit Code Review
8 changed files with 393 additions and 2 deletions

View File

@@ -35,4 +35,7 @@
<!-- Toast when we fail to launch into Dynamic System [CHAR LIMIT=64] -->
<string name="toast_failed_to_reboot_to_dynsystem">Can\u2019t restart or load dynamic system</string>
<!-- URL of Dynamic System Key Revocation List [DO NOT TRANSLATE] -->
<string name="key_revocation_list_url" translatable="false">https://dl.google.com/developers/android/gsi/gsi-keyblacklist.json</string>
</resources>

View File

@@ -46,6 +46,7 @@ import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.net.http.HttpResponseCache;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
@@ -60,6 +61,8 @@ import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import java.io.File;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
@@ -146,10 +149,26 @@ public class DynamicSystemInstallationService extends Service
prepareNotification();
mDynSystem = (DynamicSystemManager) getSystemService(Context.DYNAMIC_SYSTEM_SERVICE);
// Install an HttpResponseCache in the application cache directory so we can cache
// gsi key revocation list. The http(s) protocol handler uses this cache transparently.
// The cache size is chosen heuristically. Since we don't have too much traffic right now,
// a moderate size of 1MiB should be enough.
try {
File httpCacheDir = new File(getCacheDir(), "httpCache");
long httpCacheSize = 1 * 1024 * 1024; // 1 MiB
HttpResponseCache.install(httpCacheDir, httpCacheSize);
} catch (IOException e) {
Log.d(TAG, "HttpResponseCache.install() failed: " + e);
}
}
@Override
public void onDestroy() {
HttpResponseCache cache = HttpResponseCache.getInstalled();
if (cache != null) {
cache.flush();
}
// Cancel the persistent notification.
mNM.cancel(NOTIFICATION_ID);
}

View File

@@ -25,6 +25,8 @@ import android.os.image.DynamicSystemManager;
import android.util.Log;
import android.webkit.URLUtil;
import org.json.JSONException;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
@@ -100,7 +102,9 @@ class InstallationAsyncTask extends AsyncTask<String, InstallationAsyncTask.Prog
private final Context mContext;
private final DynamicSystemManager mDynSystem;
private final ProgressListener mListener;
private final boolean mIsNetworkUrl;
private DynamicSystemManager.Session mInstallationSession;
private KeyRevocationList mKeyRevocationList;
private boolean mIsZip;
private boolean mIsCompleted;
@@ -123,6 +127,7 @@ class InstallationAsyncTask extends AsyncTask<String, InstallationAsyncTask.Prog
mContext = context;
mDynSystem = dynSystem;
mListener = listener;
mIsNetworkUrl = URLUtil.isNetworkUrl(mUrl);
}
@Override
@@ -152,9 +157,11 @@ class InstallationAsyncTask extends AsyncTask<String, InstallationAsyncTask.Prog
return null;
}
// TODO(yochiang): do post-install public key check (revocation list / boot-ramdisk)
mDynSystem.finishInstallation();
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, e.toString(), e);
mDynSystem.remove();
return e;
} finally {
@@ -220,7 +227,7 @@ class InstallationAsyncTask extends AsyncTask<String, InstallationAsyncTask.Prog
String.format(Locale.US, "Unsupported file format: %s", mUrl));
}
if (URLUtil.isNetworkUrl(mUrl)) {
if (mIsNetworkUrl) {
mStream = new URL(mUrl).openStream();
} else if (URLUtil.isFileUrl(mUrl)) {
if (mIsZip) {
@@ -234,6 +241,25 @@ class InstallationAsyncTask extends AsyncTask<String, InstallationAsyncTask.Prog
throw new UnsupportedUrlException(
String.format(Locale.US, "Unsupported URL: %s", mUrl));
}
// TODO(yochiang): Bypass this check if device is unlocked
try {
String listUrl = mContext.getString(R.string.key_revocation_list_url);
mKeyRevocationList = KeyRevocationList.fromUrl(new URL(listUrl));
} catch (IOException | JSONException e) {
Log.d(TAG, "Failed to fetch Dynamic System Key Revocation List");
mKeyRevocationList = new KeyRevocationList();
keyRevocationThrowOrWarning(e);
}
}
private void keyRevocationThrowOrWarning(Exception e) throws Exception {
if (mIsNetworkUrl) {
throw e;
} else {
// If DSU is being installed from a local file URI, then be permissive
Log.w(TAG, e.toString());
}
}
private void installUserdata() throws Exception {

View File

@@ -0,0 +1,148 @@
/*
* Copyright (C) 2019 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.dynsystem;
import android.text.TextUtils;
import android.util.Log;
import com.android.internal.annotations.VisibleForTesting;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.HashMap;
class KeyRevocationList {
private static final String TAG = "KeyRevocationList";
private static final String JSON_ENTRIES = "entries";
private static final String JSON_PUBLIC_KEY = "public_key";
private static final String JSON_STATUS = "status";
private static final String JSON_REASON = "reason";
private static final String STATUS_REVOKED = "REVOKED";
@VisibleForTesting
HashMap<String, RevocationStatus> mEntries;
static class RevocationStatus {
final String mStatus;
final String mReason;
RevocationStatus(String status, String reason) {
mStatus = status;
mReason = reason;
}
}
KeyRevocationList() {
mEntries = new HashMap<String, RevocationStatus>();
}
/**
* Returns the revocation status of a public key.
*
* @return a RevocationStatus for |publicKey|, null if |publicKey| doesn't exist.
*/
RevocationStatus getRevocationStatusForKey(String publicKey) {
return mEntries.get(publicKey);
}
/** Test if a public key is revoked or not. */
boolean isRevoked(String publicKey) {
RevocationStatus entry = getRevocationStatusForKey(publicKey);
return entry != null && TextUtils.equals(entry.mStatus, STATUS_REVOKED);
}
@VisibleForTesting
void addEntry(String publicKey, String status, String reason) {
mEntries.put(publicKey, new RevocationStatus(status, reason));
}
/**
* Creates a KeyRevocationList from a JSON String.
*
* @param jsonString the revocation list, for example:
* <pre>{@code
* {
* "entries": [
* {
* "public_key": "00fa2c6637c399afa893fe83d85f3569998707d5",
* "status": "REVOKED",
* "reason": "Revocation Reason"
* }
* ]
* }
* }</pre>
*
* @throws JSONException if |jsonString| is malformed.
*/
static KeyRevocationList fromJsonString(String jsonString) throws JSONException {
JSONObject jsonObject = new JSONObject(jsonString);
KeyRevocationList list = new KeyRevocationList();
Log.d(TAG, "Begin of revocation list");
if (jsonObject.has(JSON_ENTRIES)) {
JSONArray entries = jsonObject.getJSONArray(JSON_ENTRIES);
for (int i = 0; i < entries.length(); ++i) {
JSONObject entry = entries.getJSONObject(i);
String publicKey = entry.getString(JSON_PUBLIC_KEY);
String status = entry.getString(JSON_STATUS);
String reason = entry.has(JSON_REASON) ? entry.getString(JSON_REASON) : "";
list.addEntry(publicKey, status, reason);
Log.d(TAG, "Revocation entry: " + entry.toString());
}
}
Log.d(TAG, "End of revocation list");
return list;
}
/**
* Creates a KeyRevocationList from a URL.
*
* @throws IOException if |url| is inaccessible.
* @throws JSONException if fetched content is malformed.
*/
static KeyRevocationList fromUrl(URL url) throws IOException, JSONException {
Log.d(TAG, "Fetch from URL: " + url.toString());
// Force "conditional GET"
// Force validate the cached result with server each time, and use the cached result
// only if it is validated by server, else fetch new data from server.
// Ref: https://developer.android.com/reference/android/net/http/HttpResponseCache#force-a-network-response
URLConnection connection = url.openConnection();
connection.setUseCaches(true);
connection.addRequestProperty("Cache-Control", "max-age=0");
try (InputStream stream = connection.getInputStream()) {
return fromJsonString(readFully(stream));
}
}
private static String readFully(InputStream in) throws IOException {
int n;
byte[] buffer = new byte[4096];
StringBuilder builder = new StringBuilder();
while ((n = in.read(buffer, 0, 4096)) > -1) {
builder.append(new String(buffer, 0, n));
}
return builder.toString();
}
}

View File

@@ -0,0 +1,15 @@
android_test {
name: "DynamicSystemInstallationServiceTests",
srcs: ["src/**/*.java"],
static_libs: [
"androidx.test.runner",
"androidx.test.rules",
"mockito-target-minus-junit4",
],
resource_dirs: ["res"],
platform_apis: true,
instrumentation_for: "DynamicSystemInstallationService",
certificate: "platform",
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2019 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.dynsystem.tests">
<application>
<uses-library android:name="android.test.runner" />
</application>
<instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
android:targetPackage="com.android.dynsystem"
android:label="Tests for DynamicSystemInstallationService" />
</manifest>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- testFromJsonString -->
<string name="blacklist_json_string" translatable="false">
{
\"entries\":[
{
\"public_key\":\"00fa2c6637c399afa893fe83d85f3569998707d5\",
\"status\":\"REVOKED\",
\"reason\":\"Key revocation test key\"
},
{
\"public_key\":\"key2\",
\"status\":\"REVOKED\"
}
]
}
</string>
</resources>

View File

@@ -0,0 +1,132 @@
/*
* Copyright (C) 2019 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.dynsystem;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import android.content.Context;
import androidx.test.filters.SmallTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import org.json.JSONException;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
/**
* A test for KeyRevocationList.java
*/
@RunWith(AndroidJUnit4.class)
public class KeyRevocationListTest {
private static final String TAG = "KeyRevocationListTest";
private static Context sContext;
private static String sBlacklistJsonString;
@BeforeClass
public static void setUpClass() throws Exception {
sContext = InstrumentationRegistry.getInstrumentation().getContext();
sBlacklistJsonString =
sContext.getString(com.android.dynsystem.tests.R.string.blacklist_json_string);
}
@Test
@SmallTest
public void testFromJsonString() throws JSONException {
KeyRevocationList blacklist;
blacklist = KeyRevocationList.fromJsonString(sBlacklistJsonString);
Assert.assertNotNull(blacklist);
Assert.assertFalse(blacklist.mEntries.isEmpty());
blacklist = KeyRevocationList.fromJsonString("{}");
Assert.assertNotNull(blacklist);
Assert.assertTrue(blacklist.mEntries.isEmpty());
}
@Test
@SmallTest
public void testFromUrl() throws IOException, JSONException {
URLConnection mockConnection = mock(URLConnection.class);
doReturn(new ByteArrayInputStream(sBlacklistJsonString.getBytes()))
.when(mockConnection).getInputStream();
URL mockUrl = new URL(
"http", // protocol
"foo.bar", // host
80, // port
"baz", // file
new URLStreamHandler() {
@Override
protected URLConnection openConnection(URL url) {
return mockConnection;
}
});
URL mockBadUrl = new URL(
"http", // protocol
"foo.bar", // host
80, // port
"baz", // file
new URLStreamHandler() {
@Override
protected URLConnection openConnection(URL url) throws IOException {
throw new IOException();
}
});
KeyRevocationList blacklist = KeyRevocationList.fromUrl(mockUrl);
Assert.assertNotNull(blacklist);
Assert.assertFalse(blacklist.mEntries.isEmpty());
blacklist = null;
try {
blacklist = KeyRevocationList.fromUrl(mockBadUrl);
// Up should throw, down should be unreachable
Assert.fail("Expected IOException not thrown");
} catch (IOException e) {
// This is expected, do nothing
}
Assert.assertNull(blacklist);
}
@Test
@SmallTest
public void testIsRevoked() {
KeyRevocationList blacklist = new KeyRevocationList();
blacklist.addEntry("key1", "REVOKED", "reason for key1");
KeyRevocationList.RevocationStatus revocationStatus =
blacklist.getRevocationStatusForKey("key1");
Assert.assertNotNull(revocationStatus);
Assert.assertEquals(revocationStatus.mReason, "reason for key1");
revocationStatus = blacklist.getRevocationStatusForKey("key2");
Assert.assertNull(revocationStatus);
Assert.assertTrue(blacklist.isRevoked("key1"));
Assert.assertFalse(blacklist.isRevoked("key2"));
}
}