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
This commit is contained in:
15
samples/Vault/Android.mk
Normal file
15
samples/Vault/Android.mk
Normal file
@@ -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)
|
||||||
20
samples/Vault/AndroidManifest.xml
Normal file
20
samples/Vault/AndroidManifest.xml
Normal file
@@ -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>
|
||||||
BIN
samples/Vault/res/drawable-xhdpi/ic_lock_lock.png
Normal file
BIN
samples/Vault/res/drawable-xhdpi/ic_lock_lock.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 954 B |
21
samples/Vault/res/values/strings.xml
Normal file
21
samples/Vault/res/values/strings.xml
Normal file
@@ -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>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
samples/Vault/src/com/example/android/vault/Utils.java
Normal file
72
samples/Vault/src/com/example/android/vault/Utils.java
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
565
samples/Vault/src/com/example/android/vault/VaultProvider.java
Normal file
565
samples/Vault/src/com/example/android/vault/VaultProvider.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
samples/Vault/tests/Android.mk
Normal file
13
samples/Vault/tests/Android.mk
Normal file
@@ -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)
|
||||||
14
samples/Vault/tests/AndroidManifest.xml
Normal file
14
samples/Vault/tests/AndroidManifest.xml
Normal file
@@ -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>
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user