summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJeff Sharkey <jsharkey@android.com>2013-10-31 11:25:31 -0700
committerJeff Sharkey <jsharkey@android.com>2013-11-12 16:00:23 -0800
commit93de41153819ff3e64f5e6cc8ec0fabd529503eb (patch)
tree7803b3c432cf66666f194a98995ab784629d50dd
parent4e5bae34615f1d8ed933e060dd8f0349a3a209be (diff)
downloadandroid_development-93de41153819ff3e64f5e6cc8ec0fabd529503eb.tar.gz
android_development-93de41153819ff3e64f5e6cc8ec0fabd529503eb.tar.bz2
android_development-93de41153819ff3e64f5e6cc8ec0fabd529503eb.zip
Vault example documents provider.
Example provider that encrypts both metadata and contents of documents stored inside. It shows advanced usage of new storage access APIs and hardware-backed key chain. Change-Id: I2cdf4e949be8471c3d8b4f45ec0681c9248ea09c
-rw-r--r--samples/Vault/Android.mk15
-rw-r--r--samples/Vault/AndroidManifest.xml20
-rw-r--r--samples/Vault/res/drawable-xhdpi/ic_lock_lock.pngbin0 -> 954 bytes
-rw-r--r--samples/Vault/res/values/strings.xml21
-rw-r--r--samples/Vault/src/com/example/android/vault/EncryptedDocument.java402
-rw-r--r--samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java114
-rw-r--r--samples/Vault/src/com/example/android/vault/Utils.java72
-rw-r--r--samples/Vault/src/com/example/android/vault/VaultProvider.java565
-rw-r--r--samples/Vault/tests/Android.mk13
-rw-r--r--samples/Vault/tests/AndroidManifest.xml14
-rw-r--r--samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java251
-rw-r--r--samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java101
12 files changed, 1588 insertions, 0 deletions
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.android.vault">
+
+ <application
+ android:label="@string/app_label"
+ android:icon="@drawable/ic_lock_lock">
+ <provider
+ android:name=".VaultProvider"
+ android:authorities="com.example.android.vault.provider"
+ android:exported="true"
+ android:grantUriPermissions="true"
+ android:permission="android.permission.MANAGE_DOCUMENTS">
+ <intent-filter>
+ <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
+ </intent-filter>
+ </provider>
+ </application>
+
+</manifest>
diff --git a/samples/Vault/res/drawable-xhdpi/ic_lock_lock.png b/samples/Vault/res/drawable-xhdpi/ic_lock_lock.png
new file mode 100644
index 000000000..086a0ca0a
--- /dev/null
+++ b/samples/Vault/res/drawable-xhdpi/ic_lock_lock.png
Binary files differ
diff --git a/samples/Vault/res/values/strings.xml b/samples/Vault/res/values/strings.xml
new file mode 100644
index 000000000..e050d257d
--- /dev/null
+++ b/samples/Vault/res/values/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<resources>
+ <string name="app_label">Vault</string>
+ <string name="info_software">Software-backed</string>
+ <string name="info_software_detail">Encryption key is software-backed, which is less secure.</string>
+</resources>
diff --git a/samples/Vault/src/com/example/android/vault/EncryptedDocument.java b/samples/Vault/src/com/example/android/vault/EncryptedDocument.java
new file mode 100644
index 000000000..59a22ba42
--- /dev/null
+++ b/samples/Vault/src/com/example/android/vault/EncryptedDocument.java
@@ -0,0 +1,402 @@
+/*
+ * 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.VaultProvider.TAG;
+
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract.Document;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.RandomAccessFile;
+import java.net.ProtocolException;
+import java.nio.charset.StandardCharsets;
+import java.security.DigestException;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+
+/**
+ * Represents a single encrypted document stored on disk. Handles encryption,
+ * decryption, and authentication of the document when requested.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * See <a href="http://en.wikipedia.org/wiki/Key_Wrap">key wrapping</a> for more
+ * details.
+ * <p>
+ * 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.
+ * <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);
+ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.android.vault.tests">
+
+ <application>
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+ <instrumentation
+ android:name="android.test.InstrumentationTestRunner"
+ android:targetPackage="com.example.android.vault"
+ android:label="Vault tests" />
+
+</manifest>
diff --git a/samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java b/samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java
new file mode 100644
index 000000000..54754fb87
--- /dev/null
+++ b/samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java
@@ -0,0 +1,251 @@
+/*
+ * 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 android.test.AndroidTestCase;
+import android.test.MoreAsserts;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.StandardCharsets;
+import java.security.DigestException;
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Tests for {@link EncryptedDocument}.
+ */
+@MediumTest
+public class EncryptedDocumentTest extends AndroidTestCase {
+
+ private File mFile;
+
+ private SecretKey mDataKey = new SecretKeySpec(new byte[] {
+ 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
+ 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01 }, "AES");
+
+ private SecretKey mMacKey = new SecretKeySpec(new byte[] {
+ 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+ 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+ 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+ 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02 }, "AES");
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+
+ mFile = new File(getContext().getFilesDir(), "meow");
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ super.tearDown();
+
+ for (File f : getContext().getFilesDir().listFiles()) {
+ f.delete();
+ }
+ }
+
+ public void testEmptyFile() throws Exception {
+ mFile.createNewFile();
+ final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+ try {
+ doc.readMetadata();
+ fail("expected metadata to throw");
+ } catch (IOException expected) {
+ }
+
+ try {
+ final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+ doc.readContent(pipe[1]);
+ fail("expected content to throw");
+ } catch (IOException expected) {
+ }
+ }
+
+ public void testNormalMetadataAndContents() throws Exception {
+ final byte[] content = "KITTENS".getBytes(StandardCharsets.UTF_8);
+ testMetadataAndContents(content);
+ }
+
+ public void testGiantMetadataAndContents() throws Exception {
+ // try with content size of prime number >1MB
+ final byte[] content = new byte[1298047];
+ Arrays.fill(content, (byte) 0x42);
+ testMetadataAndContents(content);
+ }
+
+ private void testMetadataAndContents(byte[] content) throws Exception {
+ final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+ final byte[] beforeContent = content;
+
+ final ParcelFileDescriptor[] beforePipe = ParcelFileDescriptor.createReliablePipe();
+ new Thread() {
+ @Override
+ public void run() {
+ final FileOutputStream os = new FileOutputStream(beforePipe[1].getFileDescriptor());
+ try {
+ os.write(beforeContent);
+ beforePipe[1].close();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }.start();
+
+ // fully write metadata and content
+ final JSONObject before = new JSONObject();
+ before.put("meow", "cake");
+ doc.writeMetadataAndContent(before, beforePipe[0]);
+
+ // now go back and verify we can read
+ final JSONObject after = doc.readMetadata();
+ assertEquals("cake", after.getString("meow"));
+
+ final CountDownLatch latch = new CountDownLatch(1);
+ final ParcelFileDescriptor[] afterPipe = ParcelFileDescriptor.createReliablePipe();
+ final byte[] afterContent = new byte[beforeContent.length];
+ new Thread() {
+ @Override
+ public void run() {
+ final FileInputStream is = new FileInputStream(afterPipe[0].getFileDescriptor());
+ try {
+ int i = 0;
+ while (i < afterContent.length) {
+ int n = is.read(afterContent, i, afterContent.length - i);
+ i += n;
+ }
+ afterPipe[0].close();
+ latch.countDown();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }.start();
+
+ doc.readContent(afterPipe[1]);
+ latch.await(5, TimeUnit.SECONDS);
+
+ MoreAsserts.assertEquals(beforeContent, afterContent);
+ }
+
+ public void testNormalMetadataOnly() throws Exception {
+ final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+ // write only metadata
+ final JSONObject before = new JSONObject();
+ before.put("lol", "wut");
+ doc.writeMetadataAndContent(before, null);
+
+ // verify we can read
+ final JSONObject after = doc.readMetadata();
+ assertEquals("wut", after.getString("lol"));
+
+ final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+ try {
+ doc.readContent(pipe[1]);
+ fail("found document content");
+ } catch (IOException expected) {
+ }
+ }
+
+ public void testCopiedFile() throws Exception {
+ final EncryptedDocument doc1 = new EncryptedDocument(1, mFile, mDataKey, mMacKey);
+ final EncryptedDocument doc4 = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+ // write values for doc1 into file
+ final JSONObject meta1 = new JSONObject();
+ meta1.put("key1", "value1");
+ doc1.writeMetadataAndContent(meta1, null);
+
+ // now try reading as doc4, which should fail
+ try {
+ doc4.readMetadata();
+ fail("somehow read without checking docid");
+ } catch (DigestException expected) {
+ }
+ }
+
+ public void testBitTwiddle() throws Exception {
+ final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+ // write some metadata
+ final JSONObject before = new JSONObject();
+ before.put("twiddle", "twiddle");
+ doc.writeMetadataAndContent(before, null);
+
+ final RandomAccessFile f = new RandomAccessFile(mFile, "rw");
+ f.seek(f.length() - 4);
+ f.write(0x00);
+ f.close();
+
+ try {
+ doc.readMetadata();
+ fail("somehow passed hmac");
+ } catch (DigestException expected) {
+ }
+ }
+
+ public void testErrorAbortsWrite() throws Exception {
+ final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+ // write initial metadata
+ final JSONObject init = new JSONObject();
+ init.put("color", "red");
+ doc.writeMetadataAndContent(init, null);
+
+ // try writing with a pipe that reports failure
+ final byte[] content = "KITTENS".getBytes(StandardCharsets.UTF_8);
+ final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+ new Thread() {
+ @Override
+ public void run() {
+ final FileOutputStream os = new FileOutputStream(pipe[1].getFileDescriptor());
+ try {
+ os.write(content);
+ pipe[1].closeWithError("ZOMG");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }.start();
+
+ final JSONObject second = new JSONObject();
+ second.put("color", "blue");
+ try {
+ doc.writeMetadataAndContent(second, pipe[0]);
+ fail("somehow wrote without error");
+ } catch (IOException ignored) {
+ }
+
+ // verify that original metadata still in place
+ final JSONObject after = doc.readMetadata();
+ assertEquals("red", after.getString("color"));
+ }
+}
diff --git a/samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java b/samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java
new file mode 100644
index 000000000..79e5b3313
--- /dev/null
+++ b/samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.VaultProvider.AUTHORITY;
+import static com.example.android.vault.VaultProvider.DEFAULT_DOCUMENT_ID;
+
+import android.content.ContentProviderClient;
+import android.database.Cursor;
+import android.provider.DocumentsContract.Document;
+import android.test.AndroidTestCase;
+
+import java.util.HashSet;
+
+/**
+ * Tests for {@link VaultProvider}.
+ */
+public class VaultProviderTest extends AndroidTestCase {
+
+ private static final String MIME_TYPE_DEFAULT = "text/plain";
+
+ private ContentProviderClient mClient;
+ private VaultProvider mProvider;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+
+ mClient = getContext().getContentResolver().acquireContentProviderClient(AUTHORITY);
+ mProvider = (VaultProvider) mClient.getLocalContentProvider();
+ mProvider.wipeAllContents();
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ super.tearDown();
+
+ mClient.release();
+ }
+
+ public void testDeleteDirectory() throws Exception {
+ Cursor c;
+
+ final String file = mProvider.createDocument(
+ DEFAULT_DOCUMENT_ID, MIME_TYPE_DEFAULT, "file");
+ final String dir = mProvider.createDocument(
+ DEFAULT_DOCUMENT_ID, Document.MIME_TYPE_DIR, "dir");
+
+ final String dirfile = mProvider.createDocument(
+ dir, MIME_TYPE_DEFAULT, "dirfile");
+ final String dirdir = mProvider.createDocument(
+ dir, Document.MIME_TYPE_DIR, "dirdir");
+
+ final String dirdirfile = mProvider.createDocument(
+ dirdir, MIME_TYPE_DEFAULT, "dirdirfile");
+
+ // verify everything is in place
+ c = mProvider.queryChildDocuments(DEFAULT_DOCUMENT_ID, null, null);
+ assertContains(c, "file", "dir");
+ c = mProvider.queryChildDocuments(dir, null, null);
+ assertContains(c, "dirfile", "dirdir");
+
+ // should remove children and parent ref
+ mProvider.deleteDocument(dir);
+
+ c = mProvider.queryChildDocuments(DEFAULT_DOCUMENT_ID, null, null);
+ assertContains(c, "file");
+
+ mProvider.queryDocument(file, null);
+
+ try { mProvider.queryDocument(dir, null); } catch (Exception expected) { }
+ try { mProvider.queryDocument(dirfile, null); } catch (Exception expected) { }
+ try { mProvider.queryDocument(dirdir, null); } catch (Exception expected) { }
+ try { mProvider.queryDocument(dirdirfile, null); } catch (Exception expected) { }
+ }
+
+ private static void assertContains(Cursor c, String... docs) {
+ final HashSet<String> set = new HashSet<String>();
+ while (c.moveToNext()) {
+ set.add(c.getString(c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)));
+ }
+
+ for (String doc : docs) {
+ assertTrue(doc, set.contains(doc));
+ }
+ }
+}