| /* | |
| * 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)); | |
| } | |
| } | |
| } |