blob: bf3f1ced538308be73b967df785ef12ddaf90161 [file] [log] [blame]
/*
* Copyright (C) 2013 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.example.android.vault;
import static com.example.android.vault.EncryptedDocument.DATA_KEY_LENGTH;
import static com.example.android.vault.EncryptedDocument.MAC_KEY_LENGTH;
import static com.example.android.vault.Utils.closeQuietly;
import static com.example.android.vault.Utils.closeWithErrorQuietly;
import static com.example.android.vault.Utils.readFully;
import static com.example.android.vault.Utils.writeFully;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MatrixCursor.RowBuilder;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import android.provider.DocumentsProvider;
import android.security.KeyChain;
import android.text.TextUtils;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.SecureRandom;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
/**
* Provider that encrypts both metadata and contents of documents stored inside.
* Each document is stored as described by {@link EncryptedDocument} with
* separate metadata and content sections. Directories are just
* {@link EncryptedDocument} instances without a content section, and a list of
* child documents included in the metadata section.
* <p>
* All content is encrypted/decrypted on demand through pipes, using
* {@link ParcelFileDescriptor#createReliablePipe()} to detect and recover from
* remote crashes and errors.
* <p>
* Our symmetric encryption key is stored on disk only after using
* {@link SecretKeyWrapper} to "wrap" it using another public/private key pair
* stored in the platform {@link KeyStore}. This allows us to protect our
* symmetric key with hardware-backed keys, if supported. Devices without
* hardware support still encrypt their keys while at rest, and the platform
* always requires a user to present a PIN, password, or pattern to unlock the
* KeyStore before use.
*/
public class VaultProvider extends DocumentsProvider {
public static final String TAG = "Vault";
static final String AUTHORITY = "com.example.android.vault.provider";
static final String DEFAULT_ROOT_ID = "vault";
static final String DEFAULT_DOCUMENT_ID = "0";
/** JSON key storing array of all children documents in a directory. */
private static final String KEY_CHILDREN = "vault:children";
/** Key pointing to next available document ID. */
private static final String PREF_NEXT_ID = "next_id";
/** Blob used to derive {@link #mDataKey} from our secret key. */
private static final byte[] BLOB_DATA = "DATA".getBytes(StandardCharsets.UTF_8);
/** Blob used to derive {@link #mMacKey} from our secret key. */
private static final byte[] BLOB_MAC = "MAC".getBytes(StandardCharsets.UTF_8);
private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE,
Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES, Root.COLUMN_SUMMARY
};
private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
};
private static String[] resolveRootProjection(String[] projection) {
return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
}
private static String[] resolveDocumentProjection(String[] projection) {
return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
}
private final Object mIdLock = new Object();
/**
* Flag indicating that the {@link SecretKeyWrapper} public/private key is
* hardware-backed. A software keystore is more vulnerable to offline
* attacks if the device is compromised.
*/
private boolean mHardwareBacked;
/** File where wrapped symmetric key is stored. */
private File mKeyFile;
/** Directory where all encrypted documents are stored. */
private File mDocumentsDir;
private SecretKey mDataKey;
private SecretKey mMacKey;
@Override
public boolean onCreate() {
mHardwareBacked = KeyChain.isBoundKeyAlgorithm("RSA");
mKeyFile = new File(getContext().getFilesDir(), "vault.key");
mDocumentsDir = new File(getContext().getFilesDir(), "documents");
mDocumentsDir.mkdirs();
try {
// Load secret key and ensure our root document is ready.
loadOrGenerateKeys(getContext(), mKeyFile);
initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null);
} catch (IOException e) {
throw new IllegalStateException(e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
return true;
}
/**
* Used for testing.
*/
void wipeAllContents() throws IOException, GeneralSecurityException {
for (File f : mDocumentsDir.listFiles()) {
f.delete();
}
initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null);
}
/**
* Load our symmetric secret key and use it to derive two different data and
* MAC keys. The symmetric secret key is stored securely on disk by wrapping
* it with a public/private key pair, possibly backed by hardware.
*/
private void loadOrGenerateKeys(Context context, File keyFile)
throws GeneralSecurityException, IOException {
final SecretKeyWrapper wrapper = new SecretKeyWrapper(context, TAG);
// Generate secret key if none exists
if (!keyFile.exists()) {
final byte[] raw = new byte[DATA_KEY_LENGTH];
new SecureRandom().nextBytes(raw);
final SecretKey key = new SecretKeySpec(raw, "AES");
final byte[] wrapped = wrapper.wrap(key);
writeFully(keyFile, wrapped);
}
// Even if we just generated the key, always read it back to ensure we
// can read it successfully.
final byte[] wrapped = readFully(keyFile);
final SecretKey key = wrapper.unwrap(wrapped);
final Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
// Derive two different keys for encryption and authentication.
final byte[] rawDataKey = new byte[DATA_KEY_LENGTH];
final byte[] rawMacKey = new byte[MAC_KEY_LENGTH];
System.arraycopy(mac.doFinal(BLOB_DATA), 0, rawDataKey, 0, rawDataKey.length);
System.arraycopy(mac.doFinal(BLOB_MAC), 0, rawMacKey, 0, rawMacKey.length);
mDataKey = new SecretKeySpec(rawDataKey, "AES");
mMacKey = new SecretKeySpec(rawMacKey, "HmacSHA256");
}
@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
final RowBuilder row = result.newRow();
row.add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID);
row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_LOCAL_ONLY
| Root.FLAG_SUPPORTS_IS_CHILD);
row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_label));
row.add(Root.COLUMN_DOCUMENT_ID, DEFAULT_DOCUMENT_ID);
row.add(Root.COLUMN_ICON, R.drawable.ic_lock_lock);
// Notify user in storage UI when key isn't hardware-backed
if (!mHardwareBacked) {
row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.info_software));
}
return result;
}
private EncryptedDocument getDocument(long docId) throws GeneralSecurityException {
final File file = new File(mDocumentsDir, String.valueOf(docId));
return new EncryptedDocument(docId, file, mDataKey, mMacKey);
}
/**
* Include metadata for a document in the given result cursor.
*/
private void includeDocument(MatrixCursor result, long docId)
throws IOException, GeneralSecurityException {
final EncryptedDocument doc = getDocument(docId);
if (!doc.getFile().exists()) {
throw new FileNotFoundException("Missing document " + docId);
}
final JSONObject meta = doc.readMetadata();
int flags = 0;
final String mimeType = meta.optString(Document.COLUMN_MIME_TYPE);
if (Document.MIME_TYPE_DIR.equals(mimeType)) {
flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
} else {
flags |= Document.FLAG_SUPPORTS_WRITE;
}
flags |= Document.FLAG_SUPPORTS_RENAME;
flags |= Document.FLAG_SUPPORTS_DELETE;
final RowBuilder row = result.newRow();
row.add(Document.COLUMN_DOCUMENT_ID, meta.optLong(Document.COLUMN_DOCUMENT_ID));
row.add(Document.COLUMN_DISPLAY_NAME, meta.optString(Document.COLUMN_DISPLAY_NAME));
row.add(Document.COLUMN_SIZE, meta.optLong(Document.COLUMN_SIZE));
row.add(Document.COLUMN_MIME_TYPE, mimeType);
row.add(Document.COLUMN_FLAGS, flags);
row.add(Document.COLUMN_LAST_MODIFIED, meta.optLong(Document.COLUMN_LAST_MODIFIED));
}
@Override
public boolean isChildDocument(String parentDocumentId, String documentId) {
if (TextUtils.equals(parentDocumentId, documentId)) {
return true;
}
try {
final long parentDocId = Long.parseLong(parentDocumentId);
final EncryptedDocument parentDoc = getDocument(parentDocId);
// Recursively search any children
// TODO: consider building an index to optimize this check
final JSONObject meta = parentDoc.readMetadata();
if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
for (int i = 0; i < children.length(); i++) {
final String childDocumentId = children.getString(i);
if (isChildDocument(childDocumentId, documentId)) {
return true;
}
}
}
} catch (IOException e) {
throw new IllegalStateException(e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
} catch (JSONException e) {
throw new IllegalStateException(e);
}
return false;
}
@Override
public String createDocument(String parentDocumentId, String mimeType, String displayName)
throws FileNotFoundException {
final long parentDocId = Long.parseLong(parentDocumentId);
// Allocate the next available ID
final long childDocId;
synchronized (mIdLock) {
final SharedPreferences prefs = getContext()
.getSharedPreferences(PREF_NEXT_ID, Context.MODE_PRIVATE);
childDocId = prefs.getLong(PREF_NEXT_ID, 1);
if (!prefs.edit().putLong(PREF_NEXT_ID, childDocId + 1).commit()) {
throw new IllegalStateException("Failed to allocate document ID");
}
}
try {
initDocument(childDocId, mimeType, displayName);
// Update parent to reference new child
final EncryptedDocument parentDoc = getDocument(parentDocId);
final JSONObject parentMeta = parentDoc.readMetadata();
parentMeta.accumulate(KEY_CHILDREN, childDocId);
parentDoc.writeMetadataAndContent(parentMeta, null);
return String.valueOf(childDocId);
} catch (IOException e) {
throw new IllegalStateException(e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
} catch (JSONException e) {
throw new IllegalStateException(e);
}
}
/**
* Create document on disk, writing an initial metadata section. Someone
* might come back later to write contents.
*/
private void initDocument(long docId, String mimeType, String displayName)
throws IOException, GeneralSecurityException {
final EncryptedDocument doc = getDocument(docId);
if (doc.getFile().exists()) return;
try {
final JSONObject meta = new JSONObject();
meta.put(Document.COLUMN_DOCUMENT_ID, docId);
meta.put(Document.COLUMN_MIME_TYPE, mimeType);
meta.put(Document.COLUMN_DISPLAY_NAME, displayName);
if (Document.MIME_TYPE_DIR.equals(mimeType)) {
meta.put(KEY_CHILDREN, new JSONArray());
}
doc.writeMetadataAndContent(meta, null);
} catch (JSONException e) {
throw new IOException(e);
}
}
@Override
public String renameDocument(String documentId, String displayName)
throws FileNotFoundException {
final long docId = Long.parseLong(documentId);
try {
final EncryptedDocument doc = getDocument(docId);
final JSONObject meta = doc.readMetadata();
meta.put(Document.COLUMN_DISPLAY_NAME, displayName);
doc.writeMetadataAndContent(meta, null);
return null;
} catch (IOException e) {
throw new IllegalStateException(e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
} catch (JSONException e) {
throw new IllegalStateException(e);
}
}
@Override
public void deleteDocument(String documentId) throws FileNotFoundException {
final long docId = Long.parseLong(documentId);
try {
// Delete given document, any children documents under it, and any
// references to it from parents.
deleteDocumentTree(docId);
deleteDocumentReferences(docId);
} catch (IOException e) {
throw new IllegalStateException(e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
/**
* Recursively delete the given document and any children under it.
*/
private void deleteDocumentTree(long docId) throws IOException, GeneralSecurityException {
final EncryptedDocument doc = getDocument(docId);
final JSONObject meta = doc.readMetadata();
try {
if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
for (int i = 0; i < children.length(); i++) {
final long childDocId = children.getLong(i);
deleteDocumentTree(childDocId);
}
}
} catch (JSONException e) {
throw new IOException(e);
}
if (!doc.getFile().delete()) {
throw new IOException("Failed to delete " + docId);
}
}
/**
* Remove any references to the given document, usually when included as a
* child of another directory.
*/
private void deleteDocumentReferences(long docId) {
for (String name : mDocumentsDir.list()) {
try {
final long parentDocId = Long.parseLong(name);
final EncryptedDocument parentDoc = getDocument(parentDocId);
final JSONObject meta = parentDoc.readMetadata();
if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
if (maybeRemove(children, docId)) {
Log.d(TAG, "Removed " + docId + " reference from " + name);
parentDoc.writeMetadataAndContent(meta, null);
getContext().getContentResolver().notifyChange(
DocumentsContract.buildChildDocumentsUri(AUTHORITY, name), null,
false);
}
}
} catch (NumberFormatException ignored) {
} catch (IOException e) {
Log.w(TAG, "Failed to examine " + name, e);
} catch (GeneralSecurityException e) {
Log.w(TAG, "Failed to examine " + name, e);
} catch (JSONException e) {
Log.w(TAG, "Failed to examine " + name, e);
}
}
}
@Override
public Cursor queryDocument(String documentId, String[] projection)
throws FileNotFoundException {
final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
try {
includeDocument(result, Long.parseLong(documentId));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
} catch (IOException e) {
throw new IllegalStateException(e);
}
return result;
}
@Override
public Cursor queryChildDocuments(
String parentDocumentId, String[] projection, String sortOrder)
throws FileNotFoundException {
final ExtrasMatrixCursor result = new ExtrasMatrixCursor(
resolveDocumentProjection(projection));
result.setNotificationUri(getContext().getContentResolver(),
DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId));
// Notify user in storage UI when key isn't hardware-backed
if (!mHardwareBacked) {
result.putString(DocumentsContract.EXTRA_INFO,
getContext().getString(R.string.info_software_detail));
}
try {
final EncryptedDocument doc = getDocument(Long.parseLong(parentDocumentId));
final JSONObject meta = doc.readMetadata();
final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
for (int i = 0; i < children.length(); i++) {
final long docId = children.getLong(i);
includeDocument(result, docId);
}
} catch (IOException e) {
throw new IllegalStateException(e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
} catch (JSONException e) {
throw new IllegalStateException(e);
}
return result;
}
@Override
public ParcelFileDescriptor openDocument(
String documentId, String mode, CancellationSignal signal)
throws FileNotFoundException {
final long docId = Long.parseLong(documentId);
try {
final EncryptedDocument doc = getDocument(docId);
if ("r".equals(mode)) {
return startRead(doc);
} else if ("w".equals(mode) || "wt".equals(mode)) {
return startWrite(doc);
} else {
throw new IllegalArgumentException("Unsupported mode: " + mode);
}
} catch (IOException e) {
throw new IllegalStateException(e);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
/**
* Kick off a thread to handle a read request for the given document.
* Internally creates a pipe and returns the read end for returning to a
* remote process.
*/
private ParcelFileDescriptor startRead(final EncryptedDocument doc) throws IOException {
final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
final ParcelFileDescriptor readEnd = pipe[0];
final ParcelFileDescriptor writeEnd = pipe[1];
new Thread() {
@Override
public void run() {
try {
doc.readContent(writeEnd);
Log.d(TAG, "Success reading " + doc);
closeQuietly(writeEnd);
} catch (IOException e) {
Log.w(TAG, "Failed reading " + doc, e);
closeWithErrorQuietly(writeEnd, e.toString());
} catch (GeneralSecurityException e) {
Log.w(TAG, "Failed reading " + doc, e);
closeWithErrorQuietly(writeEnd, e.toString());
}
}
}.start();
return readEnd;
}
/**
* Kick off a thread to handle a write request for the given document.
* Internally creates a pipe and returns the write end for returning to a
* remote process.
*/
private ParcelFileDescriptor startWrite(final EncryptedDocument doc) throws IOException {
final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
final ParcelFileDescriptor readEnd = pipe[0];
final ParcelFileDescriptor writeEnd = pipe[1];
new Thread() {
@Override
public void run() {
try {
final JSONObject meta = doc.readMetadata();
doc.writeMetadataAndContent(meta, readEnd);
Log.d(TAG, "Success writing " + doc);
closeQuietly(readEnd);
} catch (IOException e) {
Log.w(TAG, "Failed writing " + doc, e);
closeWithErrorQuietly(readEnd, e.toString());
} catch (GeneralSecurityException e) {
Log.w(TAG, "Failed writing " + doc, e);
closeWithErrorQuietly(readEnd, e.toString());
}
}
}.start();
return writeEnd;
}
/**
* Maybe remove the given value from a {@link JSONArray}.
*
* @return if the array was mutated.
*/
private static boolean maybeRemove(JSONArray array, long value) throws JSONException {
boolean mutated = false;
int i = 0;
while (i < array.length()) {
if (value == array.getLong(i)) {
array.remove(i);
mutated = true;
} else {
i++;
}
}
return mutated;
}
/**
* Simple extension of {@link MatrixCursor} that makes it easy to provide a
* {@link Bundle} of extras.
*/
private static class ExtrasMatrixCursor extends MatrixCursor {
private Bundle mExtras;
public ExtrasMatrixCursor(String[] columnNames) {
super(columnNames);
}
public void putString(String key, String value) {
if (mExtras == null) {
mExtras = new Bundle();
}
mExtras.putString(key, value);
}
@Override
public Bundle getExtras() {
return mExtras;
}
}
}