403 lines
14 KiB
Java
403 lines
14 KiB
Java
/*
|
|
* 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));
|
|
}
|
|
}
|
|
}
|