diff options
-rw-r--r-- | samples/Vault/Android.mk | 15 | ||||
-rw-r--r-- | samples/Vault/AndroidManifest.xml | 20 | ||||
-rw-r--r-- | samples/Vault/res/drawable-xhdpi/ic_lock_lock.png | bin | 0 -> 954 bytes | |||
-rw-r--r-- | samples/Vault/res/values/strings.xml | 21 | ||||
-rw-r--r-- | samples/Vault/src/com/example/android/vault/EncryptedDocument.java | 402 | ||||
-rw-r--r-- | samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java | 114 | ||||
-rw-r--r-- | samples/Vault/src/com/example/android/vault/Utils.java | 72 | ||||
-rw-r--r-- | samples/Vault/src/com/example/android/vault/VaultProvider.java | 565 | ||||
-rw-r--r-- | samples/Vault/tests/Android.mk | 13 | ||||
-rw-r--r-- | samples/Vault/tests/AndroidManifest.xml | 14 | ||||
-rw-r--r-- | samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java | 251 | ||||
-rw-r--r-- | samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java | 101 |
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 Binary files differnew file mode 100644 index 000000000..086a0ca0a --- /dev/null +++ b/samples/Vault/res/drawable-xhdpi/ic_lock_lock.png 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)); + } + } +} |