diff --git a/samples/Vault/Android.mk b/samples/Vault/Android.mk
new file mode 100644
index 000000000..b4de2984b
--- /dev/null
+++ b/samples/Vault/Android.mk
@@ -0,0 +1,15 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_MODULE_TAGS := optional
+LOCAL_MODULE_PATH := $(TARGET_OUT_DATA_APPS)
+
+LOCAL_SRC_FILES := $(call all-subdir-java-files)
+
+LOCAL_SDK_VERSION := current
+
+LOCAL_PROGUARD_ENABLED := disabled
+
+LOCAL_PACKAGE_NAME := Vault
+
+include $(BUILD_PACKAGE)
diff --git a/samples/Vault/AndroidManifest.xml b/samples/Vault/AndroidManifest.xml
new file mode 100644
index 000000000..8f3617231
--- /dev/null
+++ b/samples/Vault/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+ * Encrypted documents are stored on disk as a magic number, followed by an + * encrypted metadata section, followed by an encrypted content section. The + * content section always starts at a specific offset {@link #CONTENT_OFFSET} to + * allow metadata updates without rewriting the entire file. + *
+ * Each section is encrypted using AES-128 with a random IV, and authenticated + * with SHA-256. Data encrypted and authenticated like this can be safely stored + * on untrusted storage devices, as long as the keys are stored securely. + *
+ * Not inherently thread safe. + */ +public class EncryptedDocument { + + /** + * Magic number to identify file; "AVLT". + */ + private static final int MAGIC_NUMBER = 0x41564c54; + + /** + * Offset in file at which content section starts. Magic and metadata + * section must fully fit before this offset. + */ + private static final int CONTENT_OFFSET = 4096; + + private static final boolean DEBUG_METADATA = true; + + /** Key length for AES-128 */ + public static final int DATA_KEY_LENGTH = 16; + /** Key length for SHA-256 */ + public static final int MAC_KEY_LENGTH = 32; + + private final SecureRandom mRandom; + private final Cipher mCipher; + private final Mac mMac; + + private final long mDocId; + private final File mFile; + private final SecretKey mDataKey; + private final SecretKey mMacKey; + + /** + * Create an encrypted document. + * + * @param docId the expected {@link Document#COLUMN_DOCUMENT_ID} to be + * validated when reading metadata. + * @param file location on disk where the encrypted document is stored. May + * not exist yet. + */ + public EncryptedDocument(long docId, File file, SecretKey dataKey, SecretKey macKey) + throws GeneralSecurityException { + mRandom = new SecureRandom(); + mCipher = Cipher.getInstance("AES/CTR/NoPadding"); + mMac = Mac.getInstance("HmacSHA256"); + + if (dataKey.getEncoded().length != DATA_KEY_LENGTH) { + throw new IllegalArgumentException("Expected data key length " + DATA_KEY_LENGTH); + } + if (macKey.getEncoded().length != MAC_KEY_LENGTH) { + throw new IllegalArgumentException("Expected MAC key length " + MAC_KEY_LENGTH); + } + + mDocId = docId; + mFile = file; + mDataKey = dataKey; + mMacKey = macKey; + } + + public File getFile() { + return mFile; + } + + @Override + public String toString() { + return mFile.getName(); + } + + /** + * Decrypt and return parsed metadata section from this document. + * + * @throws DigestException if metadata fails MAC check, or if + * {@link Document#COLUMN_DOCUMENT_ID} recorded in metadata is + * unexpected. + */ + public JSONObject readMetadata() throws IOException, GeneralSecurityException { + final RandomAccessFile f = new RandomAccessFile(mFile, "r"); + try { + assertMagic(f); + + // Only interested in metadata section + final ByteArrayOutputStream metaOut = new ByteArrayOutputStream(); + readSection(f, metaOut); + + final String rawMeta = metaOut.toString(StandardCharsets.UTF_8.name()); + if (DEBUG_METADATA) { + Log.d(TAG, "Found metadata for " + mDocId + ": " + rawMeta); + } + + final JSONObject meta = new JSONObject(rawMeta); + + // Validate that metadata belongs to requested file + if (meta.getLong(Document.COLUMN_DOCUMENT_ID) != mDocId) { + throw new DigestException("Unexpected document ID"); + } + + return meta; + + } catch (JSONException e) { + throw new IOException(e); + } finally { + f.close(); + } + } + + /** + * Decrypt and read content section of this document, writing it into the + * given pipe. + *
+ * Pipe is left open, so caller is responsible for calling + * {@link ParcelFileDescriptor#close()} or + * {@link ParcelFileDescriptor#closeWithError(String)}. + * + * @param contentOut write end of a pipe. + * @throws DigestException if content fails MAC check. Some or all content + * may have already been written to the pipe when the MAC is + * validated. + */ + public void readContent(ParcelFileDescriptor contentOut) + throws IOException, GeneralSecurityException { + final RandomAccessFile f = new RandomAccessFile(mFile, "r"); + try { + assertMagic(f); + + if (f.length() <= CONTENT_OFFSET) { + throw new IOException("Document has no content"); + } + + // Skip over metadata section + f.seek(CONTENT_OFFSET); + readSection(f, new FileOutputStream(contentOut.getFileDescriptor())); + + } finally { + f.close(); + } + } + + /** + * Encrypt and write both the metadata and content sections of this + * document, reading the content from the given pipe. Internally uses + * {@link ParcelFileDescriptor#checkError()} to verify that content arrives + * without errors. Writes to temporary file to keep atomic view of contents, + * swapping into place only when write is successful. + *
+ * Pipe is left open, so caller is responsible for calling + * {@link ParcelFileDescriptor#close()} or + * {@link ParcelFileDescriptor#closeWithError(String)}. + * + * @param contentIn read end of a pipe. + */ + public void writeMetadataAndContent(JSONObject meta, ParcelFileDescriptor contentIn) + throws IOException, GeneralSecurityException { + // Write into temporary file to provide an atomic view of existing + // contents during write, and also to recover from failed writes. + final String tempName = mFile.getName() + ".tmp_" + Thread.currentThread().getId(); + final File tempFile = new File(mFile.getParentFile(), tempName); + + RandomAccessFile f = new RandomAccessFile(tempFile, "rw"); + try { + // Truncate any existing data + f.setLength(0); + + // Write content first to detect size + if (contentIn != null) { + f.seek(CONTENT_OFFSET); + final int plainLength = writeSection( + f, new FileInputStream(contentIn.getFileDescriptor())); + meta.put(Document.COLUMN_SIZE, plainLength); + + // Verify that remote side of pipe finished okay; if they + // crashed or indicated an error then this throws and we + // leave the original file intact and clean up temp below. + contentIn.checkError(); + } + + meta.put(Document.COLUMN_DOCUMENT_ID, mDocId); + meta.put(Document.COLUMN_LAST_MODIFIED, System.currentTimeMillis()); + + // Rewind and write metadata section + f.seek(0); + f.writeInt(MAGIC_NUMBER); + + final ByteArrayInputStream metaIn = new ByteArrayInputStream( + meta.toString().getBytes(StandardCharsets.UTF_8)); + writeSection(f, metaIn); + + if (f.getFilePointer() > CONTENT_OFFSET) { + throw new IOException("Metadata section was too large"); + } + + // Everything written fine, atomically swap new data into place. + // fsync() before close would be overkill, since rename() is an + // atomic barrier. + f.close(); + tempFile.renameTo(mFile); + + } catch (JSONException e) { + throw new IOException(e); + } finally { + // Regardless of what happens, always try cleaning up. + f.close(); + tempFile.delete(); + } + } + + /** + * Read and decrypt the section starting at the current file offset. + * Validates MAC of decrypted data, throwing if mismatch. When finished, + * file offset is at the end of the entire section. + */ + private void readSection(RandomAccessFile f, OutputStream out) + throws IOException, GeneralSecurityException { + final long start = f.getFilePointer(); + + final Section section = new Section(); + section.read(f); + + final IvParameterSpec ivSpec = new IvParameterSpec(section.iv); + mCipher.init(Cipher.DECRYPT_MODE, mDataKey, ivSpec); + mMac.init(mMacKey); + + byte[] inbuf = new byte[8192]; + byte[] outbuf; + int n; + while ((n = f.read(inbuf, 0, (int) Math.min(section.length, inbuf.length))) != -1) { + section.length -= n; + mMac.update(inbuf, 0, n); + outbuf = mCipher.update(inbuf, 0, n); + if (outbuf != null) { + out.write(outbuf); + } + if (section.length == 0) break; + } + + section.assertMac(mMac.doFinal()); + + outbuf = mCipher.doFinal(); + if (outbuf != null) { + out.write(outbuf); + } + } + + /** + * Encrypt and write the given stream as a full section. Writes section + * header and encrypted data starting at the current file offset. When + * finished, file offset is at the end of the entire section. + */ + private int writeSection(RandomAccessFile f, InputStream in) + throws IOException, GeneralSecurityException { + final long start = f.getFilePointer(); + + // Write header; we'll come back later to finalize details + final Section section = new Section(); + section.write(f); + + final long dataStart = f.getFilePointer(); + + mRandom.nextBytes(section.iv); + + final IvParameterSpec ivSpec = new IvParameterSpec(section.iv); + mCipher.init(Cipher.ENCRYPT_MODE, mDataKey, ivSpec); + mMac.init(mMacKey); + + int plainLength = 0; + byte[] inbuf = new byte[8192]; + byte[] outbuf; + int n; + while ((n = in.read(inbuf)) != -1) { + plainLength += n; + outbuf = mCipher.update(inbuf, 0, n); + if (outbuf != null) { + mMac.update(outbuf); + f.write(outbuf); + } + } + + outbuf = mCipher.doFinal(); + if (outbuf != null) { + mMac.update(outbuf); + f.write(outbuf); + } + + section.setMac(mMac.doFinal()); + + final long dataEnd = f.getFilePointer(); + section.length = dataEnd - dataStart; + + // Rewind and update header + f.seek(start); + section.write(f); + f.seek(dataEnd); + + return plainLength; + } + + /** + * Header of a single file section. + */ + private static class Section { + long length; + final byte[] iv = new byte[DATA_KEY_LENGTH]; + final byte[] mac = new byte[MAC_KEY_LENGTH]; + + public void read(RandomAccessFile f) throws IOException { + length = f.readLong(); + f.readFully(iv); + f.readFully(mac); + } + + public void write(RandomAccessFile f) throws IOException { + f.writeLong(length); + f.write(iv); + f.write(mac); + } + + public void setMac(byte[] mac) { + if (mac.length != this.mac.length) { + throw new IllegalArgumentException("Unexpected MAC length"); + } + System.arraycopy(mac, 0, this.mac, 0, this.mac.length); + } + + public void assertMac(byte[] mac) throws DigestException { + if (mac.length != this.mac.length) { + throw new IllegalArgumentException("Unexpected MAC length"); + } + byte result = 0; + for (int i = 0; i < mac.length; i++) { + result |= mac[i] ^ this.mac[i]; + } + if (result != 0) { + throw new DigestException(); + } + } + } + + private static void assertMagic(RandomAccessFile f) throws IOException { + final int magic = f.readInt(); + if (magic != MAGIC_NUMBER) { + throw new ProtocolException("Bad magic number: " + Integer.toHexString(magic)); + } + } +} diff --git a/samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java b/samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java new file mode 100644 index 000000000..cb8efde33 --- /dev/null +++ b/samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java @@ -0,0 +1,114 @@ +/* + * 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 android.content.Context; +import android.security.KeyPairGeneratorSpec; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.util.Calendar; +import java.util.GregorianCalendar; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.security.auth.x500.X500Principal; + +/** + * Wraps {@link SecretKey} instances using a public/private key pair stored in + * the platform {@link KeyStore}. This allows us to protect symmetric keys with + * hardware-backed crypto, if provided by the device. + *
+ * See key wrapping for more + * details. + *
+ * Not inherently thread safe. + */ +public class SecretKeyWrapper { + private final Cipher mCipher; + private final KeyPair mPair; + + /** + * Create a wrapper using the public/private key pair with the given alias. + * If no pair with that alias exists, it will be generated. + */ + public SecretKeyWrapper(Context context, String alias) + throws GeneralSecurityException, IOException { + mCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + + final KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + + if (!keyStore.containsAlias(alias)) { + generateKeyPair(context, alias); + } + + // Even if we just generated the key, always read it back to ensure we + // can read it successfully. + final KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry) keyStore.getEntry( + alias, null); + mPair = new KeyPair(entry.getCertificate().getPublicKey(), entry.getPrivateKey()); + } + + private static void generateKeyPair(Context context, String alias) + throws GeneralSecurityException { + final Calendar start = new GregorianCalendar(); + final Calendar end = new GregorianCalendar(); + end.add(Calendar.YEAR, 100); + + final KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder(context) + .setAlias(alias) + .setSubject(new X500Principal("CN=" + alias)) + .setSerialNumber(BigInteger.ONE) + .setStartDate(start.getTime()) + .setEndDate(end.getTime()) + .build(); + + final KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA", "AndroidKeyStore"); + gen.initialize(spec); + gen.generateKeyPair(); + } + + /** + * Wrap a {@link SecretKey} using the public key assigned to this wrapper. + * Use {@link #unwrap(byte[])} to later recover the original + * {@link SecretKey}. + * + * @return a wrapped version of the given {@link SecretKey} that can be + * safely stored on untrusted storage. + */ + public byte[] wrap(SecretKey key) throws GeneralSecurityException { + mCipher.init(Cipher.WRAP_MODE, mPair.getPublic()); + return mCipher.wrap(key); + } + + /** + * Unwrap a {@link SecretKey} using the private key assigned to this + * wrapper. + * + * @param blob a wrapped {@link SecretKey} as previously returned by + * {@link #wrap(SecretKey)}. + */ + public SecretKey unwrap(byte[] blob) throws GeneralSecurityException { + mCipher.init(Cipher.UNWRAP_MODE, mPair.getPrivate()); + return (SecretKey) mCipher.unwrap(blob, "AES", Cipher.SECRET_KEY); + } +} diff --git a/samples/Vault/src/com/example/android/vault/Utils.java b/samples/Vault/src/com/example/android/vault/Utils.java new file mode 100644 index 000000000..d4694b111 --- /dev/null +++ b/samples/Vault/src/com/example/android/vault/Utils.java @@ -0,0 +1,72 @@ +/* + * 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 android.os.ParcelFileDescriptor; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class Utils { + public static void closeQuietly(Closeable closable) { + if (closable != null) { + try { + closable.close(); + } catch (IOException ignored) { + } + } + } + + public static void closeWithErrorQuietly(ParcelFileDescriptor pfd, String msg) { + if (pfd != null) { + try { + pfd.closeWithError(msg); + } catch (IOException ignored) { + } + } + } + + public static void writeFully(File file, byte[] data) throws IOException { + final OutputStream out = new FileOutputStream(file); + try { + out.write(data); + } finally { + out.close(); + } + } + + public static byte[] readFully(File file) throws IOException { + final InputStream in = new FileInputStream(file); + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int count; + while ((count = in.read(buffer)) != -1) { + bytes.write(buffer, 0, count); + } + return bytes.toByteArray(); + } finally { + in.close(); + } + } +} diff --git a/samples/Vault/src/com/example/android/vault/VaultProvider.java b/samples/Vault/src/com/example/android/vault/VaultProvider.java new file mode 100644 index 000000000..597f7d35d --- /dev/null +++ b/samples/Vault/src/com/example/android/vault/VaultProvider.java @@ -0,0 +1,565 @@ +/* + * 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.util.Log; + +import com.android.vault.R; + +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. + *
+ * All content is encrypted/decrypted on demand through pipes, using + * {@link ParcelFileDescriptor#createReliablePipe()} to detect and recover from + * remote crashes and errors. + *
+ * 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);
+ 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_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 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 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;
+ }
+ }
+}
diff --git a/samples/Vault/tests/Android.mk b/samples/Vault/tests/Android.mk
new file mode 100644
index 000000000..552ace2fe
--- /dev/null
+++ b/samples/Vault/tests/Android.mk
@@ -0,0 +1,13 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_MODULE_TAGS := tests
+
+LOCAL_JAVA_LIBRARIES := android.test.runner
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_PACKAGE_NAME := VaultTests
+LOCAL_INSTRUMENTATION_FOR := Vault
+
+include $(BUILD_PACKAGE)
diff --git a/samples/Vault/tests/AndroidManifest.xml b/samples/Vault/tests/AndroidManifest.xml
new file mode 100644
index 000000000..8bdf682d8
--- /dev/null
+++ b/samples/Vault/tests/AndroidManifest.xml
@@ -0,0 +1,14 @@
+
+