| /* |
| * 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; |
| } |
| } |
| } |