--- /dev/null
+/*
+ * Copyright (C) 2017 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.server.locksettings.recoverablekeystore.storage;
+
+import android.annotation.Nullable;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.util.Log;
+
+import com.android.server.locksettings.recoverablekeystore.WrappedKey;
+import com.android.server.locksettings.recoverablekeystore.storage.RecoverableKeyStoreDbContract.KeysEntry;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Database of recoverable key information.
+ *
+ * @hide
+ */
+public class RecoverableKeyStoreDb {
+ private static final String TAG = "RecoverableKeyStoreDb";
+ private static final int IDLE_TIMEOUT_SECONDS = 30;
+
+ private final RecoverableKeyStoreDbHelper mKeyStoreDbHelper;
+
+ /**
+ * A new instance, storing the database in the user directory of {@code context}.
+ *
+ * @hide
+ */
+ public static RecoverableKeyStoreDb newInstance(Context context) {
+ RecoverableKeyStoreDbHelper helper = new RecoverableKeyStoreDbHelper(context);
+ helper.setWriteAheadLoggingEnabled(true);
+ helper.setIdleConnectionTimeout(IDLE_TIMEOUT_SECONDS);
+ return new RecoverableKeyStoreDb(helper);
+ }
+
+ private RecoverableKeyStoreDb(RecoverableKeyStoreDbHelper keyStoreDbHelper) {
+ this.mKeyStoreDbHelper = keyStoreDbHelper;
+ }
+
+ /**
+ * Inserts a key into the database.
+ *
+ * @param uid Uid of the application to whom the key belongs.
+ * @param alias The alias of the key in the AndroidKeyStore.
+ * @param wrappedKey The wrapped bytes of the key.
+ * @param generationId The generation ID of the platform key that wrapped the key.
+ * @return The primary key of the inserted row, or -1 if failed.
+ *
+ * @hide
+ */
+ public long insertKey(int uid, String alias, WrappedKey wrappedKey, int generationId) {
+ SQLiteDatabase db = mKeyStoreDbHelper.getWritableDatabase();
+ ContentValues values = new ContentValues();
+ values.put(KeysEntry.COLUMN_NAME_UID, uid);
+ values.put(KeysEntry.COLUMN_NAME_ALIAS, alias);
+ values.put(KeysEntry.COLUMN_NAME_NONCE, wrappedKey.getNonce());
+ values.put(KeysEntry.COLUMN_NAME_WRAPPED_KEY, wrappedKey.getKeyMaterial());
+ values.put(KeysEntry.COLUMN_NAME_LAST_SYNCED_AT, -1);
+ values.put(KeysEntry.COLUMN_NAME_GENERATION_ID, generationId);
+ return db.replace(KeysEntry.TABLE_NAME, /*nullColumnHack=*/ null, values);
+ }
+
+ /**
+ * Gets the key with {@code alias} for the app with {@code uid}.
+ *
+ * @hide
+ */
+ @Nullable public WrappedKey getKey(int uid, String alias) {
+ SQLiteDatabase db = mKeyStoreDbHelper.getReadableDatabase();
+ String[] projection = {
+ KeysEntry._ID,
+ KeysEntry.COLUMN_NAME_NONCE,
+ KeysEntry.COLUMN_NAME_WRAPPED_KEY,
+ KeysEntry.COLUMN_NAME_GENERATION_ID};
+ String selection =
+ KeysEntry.COLUMN_NAME_UID + " = ? AND "
+ + KeysEntry.COLUMN_NAME_ALIAS + " = ?";
+ String[] selectionArguments = { Integer.toString(uid), alias };
+
+ try (
+ Cursor cursor = db.query(
+ KeysEntry.TABLE_NAME,
+ projection,
+ selection,
+ selectionArguments,
+ /*groupBy=*/ null,
+ /*having=*/ null,
+ /*orderBy=*/ null)
+ ) {
+ int count = cursor.getCount();
+ if (count == 0) {
+ return null;
+ }
+ if (count > 1) {
+ Log.wtf(TAG,
+ String.format(Locale.US,
+ "%d WrappedKey entries found for uid=%d alias='%s'. "
+ + "Should only ever be 0 or 1.", count, uid, alias));
+ return null;
+ }
+ cursor.moveToFirst();
+ byte[] nonce = cursor.getBlob(
+ cursor.getColumnIndexOrThrow(KeysEntry.COLUMN_NAME_NONCE));
+ byte[] keyMaterial = cursor.getBlob(
+ cursor.getColumnIndexOrThrow(KeysEntry.COLUMN_NAME_WRAPPED_KEY));
+ return new WrappedKey(nonce, keyMaterial);
+ }
+ }
+
+ /**
+ * Returns all keys for the given {@code uid} and {@code platformKeyGenerationId}.
+ *
+ * @param uid User id of the profile to which all the keys are associated.
+ * @param platformKeyGenerationId The generation ID of the platform key that wrapped these keys.
+ * (i.e., this should be the most recent generation ID, as older platform keys are not
+ * usable.)
+ *
+ * @hide
+ */
+ public Map<String, WrappedKey> getAllKeys(int uid, int platformKeyGenerationId) {
+ SQLiteDatabase db = mKeyStoreDbHelper.getReadableDatabase();
+ String[] projection = {
+ KeysEntry._ID,
+ KeysEntry.COLUMN_NAME_NONCE,
+ KeysEntry.COLUMN_NAME_WRAPPED_KEY,
+ KeysEntry.COLUMN_NAME_ALIAS};
+ String selection =
+ KeysEntry.COLUMN_NAME_UID + " = ? AND "
+ + KeysEntry.COLUMN_NAME_GENERATION_ID + " = ?";
+ String[] selectionArguments = {
+ Integer.toString(uid), Integer.toString(platformKeyGenerationId) };
+
+ try (
+ Cursor cursor = db.query(
+ KeysEntry.TABLE_NAME,
+ projection,
+ selection,
+ selectionArguments,
+ /*groupBy=*/ null,
+ /*having=*/ null,
+ /*orderBy=*/ null)
+ ) {
+ HashMap<String, WrappedKey> keys = new HashMap<>();
+ while (cursor.moveToNext()) {
+ byte[] nonce = cursor.getBlob(
+ cursor.getColumnIndexOrThrow(KeysEntry.COLUMN_NAME_NONCE));
+ byte[] keyMaterial = cursor.getBlob(
+ cursor.getColumnIndexOrThrow(KeysEntry.COLUMN_NAME_WRAPPED_KEY));
+ String alias = cursor.getString(
+ cursor.getColumnIndexOrThrow(KeysEntry.COLUMN_NAME_ALIAS));
+ keys.put(alias, new WrappedKey(nonce, keyMaterial));
+ }
+ return keys;
+ }
+ }
+
+ /**
+ * Closes all open connections to the database.
+ */
+ public void close() {
+ mKeyStoreDbHelper.close();
+ }
+
+ // TODO: Add method for updating the 'last synced' time.
+}
--- /dev/null
+/*
+ * Copyright (C) 2017 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.server.locksettings.recoverablekeystore.storage;
+
+import android.provider.BaseColumns;
+
+/**
+ * Contract for recoverable key database. Describes the tables present.
+ */
+class RecoverableKeyStoreDbContract {
+ /**
+ * Table holding wrapped keys, and information about when they were last synced.
+ */
+ static class KeysEntry implements BaseColumns {
+ static final String TABLE_NAME = "keys";
+
+ /**
+ * The uid of the application that generated the key.
+ */
+ static final String COLUMN_NAME_UID = "uid";
+
+ /**
+ * The alias of the key, as set in AndroidKeyStore.
+ */
+ static final String COLUMN_NAME_ALIAS = "alias";
+
+ /**
+ * Nonce with which the key was encrypted.
+ */
+ static final String COLUMN_NAME_NONCE = "nonce";
+
+ /**
+ * Encrypted bytes of the key.
+ */
+ static final String COLUMN_NAME_WRAPPED_KEY = "wrapped_key";
+
+ /**
+ * Generation ID of the platform key that was used to encrypt this key.
+ */
+ static final String COLUMN_NAME_GENERATION_ID = "platform_key_generation_id";
+
+ /**
+ * Timestamp of when this key was last synced with remote storage, or -1 if never synced.
+ */
+ static final String COLUMN_NAME_LAST_SYNCED_AT = "last_synced_at";
+ }
+}
--- /dev/null
+package com.android.server.locksettings.recoverablekeystore.storage;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import com.android.server.locksettings.recoverablekeystore.storage.RecoverableKeyStoreDbContract.KeysEntry;
+
+/**
+ * Helper for creating the recoverable key database.
+ */
+class RecoverableKeyStoreDbHelper extends SQLiteOpenHelper {
+ private static final int DATABASE_VERSION = 1;
+ private static final String DATABASE_NAME = "recoverablekeystore.db";
+
+ private static final String SQL_CREATE_ENTRIES =
+ "CREATE TABLE " + KeysEntry.TABLE_NAME + "( "
+ + KeysEntry._ID + " INTEGER PRIMARY KEY,"
+ + KeysEntry.COLUMN_NAME_UID + " INTEGER UNIQUE,"
+ + KeysEntry.COLUMN_NAME_ALIAS + " TEXT UNIQUE,"
+ + KeysEntry.COLUMN_NAME_NONCE + " BLOB,"
+ + KeysEntry.COLUMN_NAME_WRAPPED_KEY + " BLOB,"
+ + KeysEntry.COLUMN_NAME_GENERATION_ID + " INTEGER,"
+ + KeysEntry.COLUMN_NAME_LAST_SYNCED_AT + " INTEGER)";
+
+ private static final String SQL_DELETE_ENTRIES =
+ "DROP TABLE IF EXISTS " + KeysEntry.TABLE_NAME;
+
+ RecoverableKeyStoreDbHelper(Context context) {
+ super(context, DATABASE_NAME, null, DATABASE_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL(SQL_CREATE_ENTRIES);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ db.execSQL(SQL_DELETE_ENTRIES);
+ onCreate(db);
+ }
+}
--- /dev/null
+/*
+ * Copyright (C) 2017 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.server.locksettings.recoverablekeystore.storage;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import android.content.Context;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.server.locksettings.recoverablekeystore.WrappedKey;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class RecoverableKeyStoreDbTest {
+ private static final String DATABASE_FILE_NAME = "recoverablekeystore.db";
+
+ private RecoverableKeyStoreDb mRecoverableKeyStoreDb;
+ private File mDatabaseFile;
+
+ @Before
+ public void setUp() {
+ Context context = InstrumentationRegistry.getTargetContext();
+ mDatabaseFile = context.getDatabasePath(DATABASE_FILE_NAME);
+ mRecoverableKeyStoreDb = RecoverableKeyStoreDb.newInstance(context);
+ }
+
+ @After
+ public void tearDown() {
+ mRecoverableKeyStoreDb.close();
+ mDatabaseFile.delete();
+ }
+
+ @Test
+ public void insertKey_replacesOldKey() {
+ int userId = 12;
+ String alias = "test";
+ WrappedKey oldWrappedKey = new WrappedKey(
+ getUtf8Bytes("nonce1"), getUtf8Bytes("keymaterial1"));
+ mRecoverableKeyStoreDb.insertKey(
+ userId, alias, oldWrappedKey, /*generationId=*/ 1);
+ byte[] nonce = getUtf8Bytes("nonce2");
+ byte[] keyMaterial = getUtf8Bytes("keymaterial2");
+ WrappedKey newWrappedKey = new WrappedKey(nonce, keyMaterial);
+
+ mRecoverableKeyStoreDb.insertKey(
+ userId, alias, newWrappedKey, /*generationId=*/ 2);
+
+ WrappedKey retrievedKey = mRecoverableKeyStoreDb.getKey(userId, alias);
+ assertArrayEquals(nonce, retrievedKey.getNonce());
+ assertArrayEquals(keyMaterial, retrievedKey.getKeyMaterial());
+ }
+
+ @Test
+ public void getKey_returnsNullIfNoKey() {
+ WrappedKey key = mRecoverableKeyStoreDb.getKey(
+ /*userId=*/ 1, /*alias=*/ "hello");
+
+ assertNull(key);
+ }
+
+ @Test
+ public void getKey_returnsInsertedKey() {
+ int userId = 12;
+ int generationId = 6;
+ String alias = "test";
+ byte[] nonce = getUtf8Bytes("nonce");
+ byte[] keyMaterial = getUtf8Bytes("keymaterial");
+ WrappedKey wrappedKey = new WrappedKey(nonce, keyMaterial);
+ mRecoverableKeyStoreDb.insertKey(userId, alias, wrappedKey, generationId);
+
+ WrappedKey retrievedKey = mRecoverableKeyStoreDb.getKey(userId, alias);
+
+ assertArrayEquals(nonce, retrievedKey.getNonce());
+ assertArrayEquals(keyMaterial, retrievedKey.getKeyMaterial());
+ }
+
+ @Test
+ public void getAllKeys_getsKeysWithUserIdAndGenerationId() {
+ int userId = 12;
+ int generationId = 6;
+ String alias = "test";
+ byte[] nonce = getUtf8Bytes("nonce");
+ byte[] keyMaterial = getUtf8Bytes("keymaterial");
+ WrappedKey wrappedKey = new WrappedKey(nonce, keyMaterial);
+ mRecoverableKeyStoreDb.insertKey(userId, alias, wrappedKey, generationId);
+
+ Map<String, WrappedKey> keys = mRecoverableKeyStoreDb.getAllKeys(userId, generationId);
+
+ assertEquals(1, keys.size());
+ assertTrue(keys.containsKey(alias));
+ WrappedKey retrievedKey = keys.get(alias);
+ assertArrayEquals(nonce, retrievedKey.getNonce());
+ assertArrayEquals(keyMaterial, retrievedKey.getKeyMaterial());
+ }
+
+ @Test
+ public void getAllKeys_doesNotReturnKeysWithBadGenerationId() {
+ int userId = 12;
+ WrappedKey wrappedKey = new WrappedKey(
+ getUtf8Bytes("nonce"), getUtf8Bytes("keymaterial"));
+ mRecoverableKeyStoreDb.insertKey(
+ userId, /*alias=*/ "test", wrappedKey, /*generationId=*/ 5);
+
+ Map<String, WrappedKey> keys = mRecoverableKeyStoreDb.getAllKeys(
+ userId, /*generationId=*/ 7);
+
+ assertTrue(keys.isEmpty());
+ }
+
+ @Test
+ public void getAllKeys_doesNotReturnKeysWithBadUserId() {
+ int generationId = 12;
+ WrappedKey wrappedKey = new WrappedKey(
+ getUtf8Bytes("nonce"), getUtf8Bytes("keymaterial"));
+ mRecoverableKeyStoreDb.insertKey(
+ /*userId=*/ 1, /*alias=*/ "test", wrappedKey, generationId);
+
+ Map<String, WrappedKey> keys = mRecoverableKeyStoreDb.getAllKeys(
+ /*userId=*/ 2, generationId);
+
+ assertTrue(keys.isEmpty());
+ }
+
+ private static byte[] getUtf8Bytes(String s) {
+ return s.getBytes(StandardCharsets.UTF_8);
+ }
+}