diff options
6 files changed, 1087 insertions, 2 deletions
diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupFileDecryptorTask.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupFileDecryptorTask.java new file mode 100644 index 000000000000..9bf148ddc901 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupFileDecryptorTask.java @@ -0,0 +1,378 @@ +/* + * Copyright (C) 2019 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.android.server.backup.encryption.tasks; + +import android.util.Slog; +import android.util.SparseIntArray; + +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto.ChunkOrdering; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto.ChunksMetadata; + +import com.google.protobuf.nano.InvalidProtocolBufferNanoException; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Locale; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.GCMParameterSpec; + +/** + * A backup file consists of, in order: + * + * <ul> + * <li>A randomly ordered sequence of encrypted chunks + * <li>A plaintext {@link ChunksMetadata} proto, containing the bytes of an encrypted {@link + * ChunkOrdering} proto. + * <li>A 64-bit long denoting the offset of the file at which the ChunkOrdering proto starts. + * </ul> + * + * <p>This task decrypts such a blob and writes the plaintext to another file. + * + * <p>The backup file has two formats to indicate the boundaries of the chunks in the encrypted + * file. In {@link ChunksMetadataProto#EXPLICIT_STARTS} mode the chunk ordering contains the start + * positions of each chunk and the decryptor outputs the chunks in the order they appeared in the + * plaintext file. In {@link ChunksMetadataProto#INLINE_LENGTHS} mode the length of each encrypted + * chunk is prepended to the chunk in the file and the decryptor outputs the chunks in no specific + * order. + * + * <p>{@link ChunksMetadataProto#EXPLICIT_STARTS} is for use with full backup (Currently used for + * all backups as b/77188289 is not implemented yet), {@link ChunksMetadataProto#INLINE_LENGTHS} + * will be used for kv backup (once b/77188289 is implemented) to avoid re-uploading the chunk + * ordering (see b/70782620). + */ +public class BackupFileDecryptorTask { + private static final String TAG = "BackupFileDecryptorTask"; + + private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_NONCE_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BYTES = 16; + private static final int BITS_PER_BYTE = 8; + private static final String READ_MODE = "r"; + private static final int BYTES_PER_LONG = 64 / BITS_PER_BYTE; + + private final Cipher mCipher; + private final SecretKey mSecretKey; + + /** + * A new instance. + * + * @param secretKey The tertiary key used to encrypt the backup blob. + */ + public BackupFileDecryptorTask(SecretKey secretKey) + throws NoSuchPaddingException, NoSuchAlgorithmException { + this.mCipher = Cipher.getInstance(CIPHER_ALGORITHM); + this.mSecretKey = secretKey; + } + + /** + * Runs the task, reading the encrypted data from {@code input} and writing the plaintext data + * to {@code output}. + * + * @param inputFile The encrypted backup file. + * @param decryptedChunkOutput Unopened output to write the plaintext to, which this class will + * open and close during decryption. + * @throws IOException if an error occurred reading the encrypted file or writing the plaintext, + * or if one of the protos could not be deserialized. + */ + public void decryptFile(File inputFile, DecryptedChunkOutput decryptedChunkOutput) + throws IOException, EncryptedRestoreException, IllegalBlockSizeException, + BadPaddingException, InvalidAlgorithmParameterException, InvalidKeyException, + ShortBufferException, NoSuchAlgorithmException { + RandomAccessFile input = new RandomAccessFile(inputFile, READ_MODE); + + long metadataOffset = getChunksMetadataOffset(input); + ChunksMetadataProto.ChunksMetadata chunksMetadata = + getChunksMetadata(input, metadataOffset); + ChunkOrdering chunkOrdering = decryptChunkOrdering(chunksMetadata); + + if (chunksMetadata.chunkOrderingType == ChunksMetadataProto.CHUNK_ORDERING_TYPE_UNSPECIFIED + || chunksMetadata.chunkOrderingType == ChunksMetadataProto.EXPLICIT_STARTS) { + Slog.d(TAG, "Using explicit starts"); + decryptFileWithExplicitStarts( + input, decryptedChunkOutput, chunkOrdering, metadataOffset); + + } else if (chunksMetadata.chunkOrderingType == ChunksMetadataProto.INLINE_LENGTHS) { + Slog.d(TAG, "Using inline lengths"); + decryptFileWithInlineLengths(input, decryptedChunkOutput, metadataOffset); + + } else { + throw new UnsupportedEncryptedFileException( + "Unknown chunk ordering type:" + chunksMetadata.chunkOrderingType); + } + + if (!Arrays.equals(decryptedChunkOutput.getDigest(), chunkOrdering.checksum)) { + throw new MessageDigestMismatchException("Checksums did not match"); + } + } + + private void decryptFileWithExplicitStarts( + RandomAccessFile input, + DecryptedChunkOutput decryptedChunkOutput, + ChunkOrdering chunkOrdering, + long metadataOffset) + throws IOException, InvalidKeyException, IllegalBlockSizeException, + InvalidAlgorithmParameterException, ShortBufferException, BadPaddingException, + NoSuchAlgorithmException { + SparseIntArray chunkLengthsByPosition = + getChunkLengths(chunkOrdering.starts, (int) metadataOffset); + int largestChunkLength = getLargestChunkLength(chunkLengthsByPosition); + byte[] encryptedChunkBuffer = new byte[largestChunkLength]; + // largestChunkLength is 0 if the backup file contains zero chunks e.g. 0 kv pairs. + int plaintextBufferLength = + Math.max(0, largestChunkLength - GCM_NONCE_LENGTH_BYTES - GCM_TAG_LENGTH_BYTES); + byte[] plaintextChunkBuffer = new byte[plaintextBufferLength]; + + try (DecryptedChunkOutput output = decryptedChunkOutput.open()) { + for (int start : chunkOrdering.starts) { + int length = chunkLengthsByPosition.get(start); + + input.seek(start); + input.readFully(encryptedChunkBuffer, 0, length); + int plaintextLength = + decryptChunk(encryptedChunkBuffer, length, plaintextChunkBuffer); + outputChunk(output, plaintextChunkBuffer, plaintextLength); + } + } + } + + private void decryptFileWithInlineLengths( + RandomAccessFile input, DecryptedChunkOutput decryptedChunkOutput, long metadataOffset) + throws MalformedEncryptedFileException, IOException, IllegalBlockSizeException, + BadPaddingException, InvalidAlgorithmParameterException, ShortBufferException, + InvalidKeyException, NoSuchAlgorithmException { + input.seek(0); + try (DecryptedChunkOutput output = decryptedChunkOutput.open()) { + while (input.getFilePointer() < metadataOffset) { + long start = input.getFilePointer(); + int encryptedChunkLength = input.readInt(); + + if (encryptedChunkLength <= 0) { + // If the length of the encrypted chunk is not positive we will not make + // progress reading the file and so will loop forever. + throw new MalformedEncryptedFileException( + "Encrypted chunk length not positive:" + encryptedChunkLength); + } + + if (start + encryptedChunkLength > metadataOffset) { + throw new MalformedEncryptedFileException( + String.format( + Locale.US, + "Encrypted chunk longer (%d) than file (%d)", + encryptedChunkLength, + metadataOffset)); + } + + byte[] plaintextChunk = new byte[encryptedChunkLength]; + byte[] plaintext = + new byte + [encryptedChunkLength + - GCM_NONCE_LENGTH_BYTES + - GCM_TAG_LENGTH_BYTES]; + + input.readFully(plaintextChunk); + + int plaintextChunkLength = + decryptChunk(plaintextChunk, encryptedChunkLength, plaintext); + outputChunk(output, plaintext, plaintextChunkLength); + } + } + } + + private void outputChunk( + DecryptedChunkOutput output, byte[] plaintextChunkBuffer, int plaintextLength) + throws IOException, InvalidKeyException, NoSuchAlgorithmException { + output.processChunk(plaintextChunkBuffer, plaintextLength); + } + + /** + * Decrypts chunk and returns the length of the plaintext. + * + * @param encryptedChunkBuffer The encrypted data, prefixed by the nonce. + * @param encryptedChunkBufferLength The length of the encrypted chunk (including nonce). + * @param plaintextChunkBuffer The buffer into which to write the plaintext chunk. + * @return The length of the plaintext chunk. + */ + private int decryptChunk( + byte[] encryptedChunkBuffer, + int encryptedChunkBufferLength, + byte[] plaintextChunkBuffer) + throws InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, + ShortBufferException, IllegalBlockSizeException { + + mCipher.init( + Cipher.DECRYPT_MODE, + mSecretKey, + new GCMParameterSpec( + GCM_TAG_LENGTH_BYTES * BITS_PER_BYTE, + encryptedChunkBuffer, + 0, + GCM_NONCE_LENGTH_BYTES)); + + return mCipher.doFinal( + encryptedChunkBuffer, + GCM_NONCE_LENGTH_BYTES, + encryptedChunkBufferLength - GCM_NONCE_LENGTH_BYTES, + plaintextChunkBuffer); + } + + /** Given all the lengths, returns the largest length. */ + private int getLargestChunkLength(SparseIntArray lengths) { + int maxSeen = 0; + for (int i = 0; i < lengths.size(); i++) { + maxSeen = Math.max(maxSeen, lengths.valueAt(i)); + } + return maxSeen; + } + + /** + * From a list of the starting position of each chunk in the correct order of the backup data, + * calculates a mapping from start position to length of that chunk. + * + * @param starts The start positions of chunks, in order. + * @param chunkOrderingPosition Where the {@link ChunkOrdering} proto starts, used to calculate + * the length of the last chunk. + * @return The mapping. + */ + private SparseIntArray getChunkLengths(int[] starts, int chunkOrderingPosition) { + int[] boundaries = Arrays.copyOf(starts, starts.length + 1); + boundaries[boundaries.length - 1] = chunkOrderingPosition; + Arrays.sort(boundaries); + + SparseIntArray lengths = new SparseIntArray(); + for (int i = 0; i < boundaries.length - 1; i++) { + lengths.put(boundaries[i], boundaries[i + 1] - boundaries[i]); + } + return lengths; + } + + /** + * Reads and decrypts the {@link ChunkOrdering} from the {@link ChunksMetadata}. + * + * @param metadata The metadata. + * @return The ordering. + * @throws InvalidProtocolBufferNanoException if there is an issue deserializing the proto. + */ + private ChunkOrdering decryptChunkOrdering(ChunksMetadata metadata) + throws InvalidProtocolBufferNanoException, InvalidAlgorithmParameterException, + InvalidKeyException, BadPaddingException, IllegalBlockSizeException, + UnsupportedEncryptedFileException { + assertCryptoSupported(metadata); + + mCipher.init( + Cipher.DECRYPT_MODE, + mSecretKey, + new GCMParameterSpec( + GCM_TAG_LENGTH_BYTES * BITS_PER_BYTE, + metadata.chunkOrdering, + 0, + GCM_NONCE_LENGTH_BYTES)); + + byte[] decrypted = + mCipher.doFinal( + metadata.chunkOrdering, + GCM_NONCE_LENGTH_BYTES, + metadata.chunkOrdering.length - GCM_NONCE_LENGTH_BYTES); + + return ChunkOrdering.parseFrom(decrypted); + } + + /** + * Asserts that the Cipher and MessageDigest algorithms in the backup metadata are supported. + * For now we only support SHA-256 for checksum and 256-bit AES/GCM/NoPadding for the Cipher. + * + * @param chunksMetadata The file metadata. + * @throws UnsupportedEncryptedFileException if any algorithm is unsupported. + */ + private void assertCryptoSupported(ChunksMetadata chunksMetadata) + throws UnsupportedEncryptedFileException { + if (chunksMetadata.checksumType != ChunksMetadataProto.SHA_256) { + // For now we only support SHA-256. + throw new UnsupportedEncryptedFileException( + "Unrecognized checksum type for backup (this version of backup only supports" + + " SHA-256): " + + chunksMetadata.checksumType); + } + + if (chunksMetadata.cipherType != ChunksMetadataProto.AES_256_GCM) { + throw new UnsupportedEncryptedFileException( + "Unrecognized cipher type for backup (this version of backup only supports" + + " AES-256-GCM: " + + chunksMetadata.cipherType); + } + } + + /** + * Reads the offset of the {@link ChunksMetadata} proto from the end of the file. + * + * @return The offset. + * @throws IOException if there is an error reading. + */ + private long getChunksMetadataOffset(RandomAccessFile input) throws IOException { + input.seek(input.length() - BYTES_PER_LONG); + return input.readLong(); + } + + /** + * Reads the {@link ChunksMetadata} proto from the given position in the file. + * + * @param input The encrypted file. + * @param position The position where the proto starts. + * @return The proto. + * @throws IOException if there is an issue reading the file or deserializing the proto. + */ + private ChunksMetadata getChunksMetadata(RandomAccessFile input, long position) + throws IOException, MalformedEncryptedFileException { + long length = input.length(); + if (position >= length || position < 0) { + throw new MalformedEncryptedFileException( + String.format( + Locale.US, + "%d is not valid position for chunks metadata in file of %d bytes", + position, + length)); + } + + // Read chunk ordering bytes + input.seek(position); + long chunksMetadataLength = input.length() - BYTES_PER_LONG - position; + byte[] chunksMetadataBytes = new byte[(int) chunksMetadataLength]; + input.readFully(chunksMetadataBytes); + + try { + return ChunksMetadata.parseFrom(chunksMetadataBytes); + } catch (InvalidProtocolBufferNanoException e) { + throw new MalformedEncryptedFileException( + String.format( + Locale.US, + "Could not read chunks metadata at position %d of file of %d bytes", + position, + length)); + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/MalformedEncryptedFileException.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/MalformedEncryptedFileException.java new file mode 100644 index 000000000000..78c370b0d548 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/MalformedEncryptedFileException.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2019 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.android.server.backup.encryption.tasks; + +/** Exception thrown when we cannot parse the encrypted backup file. */ +public class MalformedEncryptedFileException extends EncryptedRestoreException { + public MalformedEncryptedFileException(String message) { + super(message); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/MessageDigestMismatchException.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/MessageDigestMismatchException.java new file mode 100644 index 000000000000..1e4f43b43e26 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/MessageDigestMismatchException.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2019 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.android.server.backup.encryption.tasks; + +/** + * Error thrown if the message digest of the plaintext backup does not match that in the {@link + * com.android.server.backup.encryption.protos.ChunksMetadataProto.ChunkOrdering}. + */ +public class MessageDigestMismatchException extends EncryptedRestoreException { + public MessageDigestMismatchException(String message) { + super(message); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/UnsupportedEncryptedFileException.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/UnsupportedEncryptedFileException.java new file mode 100644 index 000000000000..9a97e3870d83 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/UnsupportedEncryptedFileException.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2019 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.android.server.backup.encryption.tasks; + +/** + * Thrown when the backup file provided by the server uses encryption algorithms this version of + * backup does not support. This could happen if the backup was created with a newer version of the + * code. + */ +public class UnsupportedEncryptedFileException extends EncryptedRestoreException { + public UnsupportedEncryptedFileException(String message) { + super(message); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupFileDecryptorTaskTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupFileDecryptorTaskTest.java new file mode 100644 index 000000000000..07a6fd2d5b60 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupFileDecryptorTaskTest.java @@ -0,0 +1,583 @@ +/* + * Copyright (C) 2019 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.android.server.backup.encryption.tasks; + +import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey; +import static com.android.server.backup.testing.CryptoTestUtils.newChunkOrdering; +import static com.android.server.backup.testing.CryptoTestUtils.newChunksMetadata; +import static com.android.server.backup.testing.CryptoTestUtils.newPair; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.expectThrows; + +import android.annotation.Nullable; +import android.app.backup.BackupDataInput; +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.chunk.ChunkHash; +import com.android.server.backup.encryption.chunking.ChunkHasher; +import com.android.server.backup.encryption.chunking.DecryptedChunkFileOutput; +import com.android.server.backup.encryption.chunking.EncryptedChunk; +import com.android.server.backup.encryption.chunking.cdc.FingerprintMixer; +import com.android.server.backup.encryption.kv.DecryptedChunkKvOutput; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto.ChunkOrdering; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto.ChunksMetadata; +import com.android.server.backup.encryption.protos.nano.KeyValuePairProto.KeyValuePair; +import com.android.server.backup.encryption.tasks.BackupEncrypter.Result; +import com.android.server.backup.testing.CryptoTestUtils; +import com.android.server.testing.shadows.ShadowBackupDataInput; + +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.nano.MessageNano; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.RandomAccessFile; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import javax.crypto.AEADBadTagException; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +@Config(shadows = {ShadowBackupDataInput.class}) +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class BackupFileDecryptorTaskTest { + private static final String READ_WRITE_MODE = "rw"; + private static final int BYTES_PER_KILOBYTE = 1024; + private static final int MIN_CHUNK_SIZE_BYTES = 2 * BYTES_PER_KILOBYTE; + private static final int AVERAGE_CHUNK_SIZE_BYTES = 4 * BYTES_PER_KILOBYTE; + private static final int MAX_CHUNK_SIZE_BYTES = 64 * BYTES_PER_KILOBYTE; + private static final int BACKUP_DATA_SIZE_BYTES = 60 * BYTES_PER_KILOBYTE; + private static final int GCM_NONCE_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BYTES = 16; + private static final int BITS_PER_BYTE = 8; + private static final int CHECKSUM_LENGTH_BYTES = 256 / BITS_PER_BYTE; + @Nullable private static final FileDescriptor NULL_FILE_DESCRIPTOR = null; + + private static final Set<KeyValuePair> TEST_KV_DATA = new HashSet<>(); + + static { + TEST_KV_DATA.add(newPair("key1", "value1")); + TEST_KV_DATA.add(newPair("key2", "value2")); + } + + @Rule public final TemporaryFolder mTemporaryFolder = new TemporaryFolder(); + + private SecretKey mTertiaryKey; + private SecretKey mChunkEncryptionKey; + private File mInputFile; + private File mOutputFile; + private DecryptedChunkOutput mFileOutput; + private DecryptedChunkKvOutput mKvOutput; + private Random mRandom; + private BackupFileDecryptorTask mTask; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + mRandom = new Random(); + mTertiaryKey = generateAesKey(); + // In good situations it's always the same. We allow changing it for testing when somehow it + // has become mismatched that we throw an error. + mChunkEncryptionKey = mTertiaryKey; + mInputFile = mTemporaryFolder.newFile(); + mOutputFile = mTemporaryFolder.newFile(); + mFileOutput = new DecryptedChunkFileOutput(mOutputFile); + mKvOutput = new DecryptedChunkKvOutput(new ChunkHasher(mTertiaryKey)); + mTask = new BackupFileDecryptorTask(mTertiaryKey); + } + + @Test + public void decryptFile_throwsForNonExistentInput() throws Exception { + assertThrows( + FileNotFoundException.class, + () -> + mTask.decryptFile( + new File(mTemporaryFolder.newFolder(), "nonexistent"), + mFileOutput)); + } + + @Test + public void decryptFile_throwsForDirectoryInputFile() throws Exception { + assertThrows( + FileNotFoundException.class, + () -> mTask.decryptFile(mTemporaryFolder.newFolder(), mFileOutput)); + } + + @Test + public void decryptFile_withExplicitStarts_decryptsEncryptedData() throws Exception { + byte[] backupData = randomData(BACKUP_DATA_SIZE_BYTES); + createEncryptedFileUsingExplicitStarts(backupData); + + mTask.decryptFile(mInputFile, mFileOutput); + + assertThat(Files.readAllBytes(Paths.get(mOutputFile.toURI()))).isEqualTo(backupData); + } + + @Test + public void decryptFile_withInlineLengths_decryptsEncryptedData() throws Exception { + createEncryptedFileUsingInlineLengths( + TEST_KV_DATA, chunkOrdering -> chunkOrdering, chunksMetadata -> chunksMetadata); + mTask.decryptFile(mInputFile, mKvOutput); + assertThat(asMap(mKvOutput.getPairs())).containsExactlyEntriesIn(asMap(TEST_KV_DATA)); + } + + @Test + public void decryptFile_withNoChunkOrderingType_decryptsUsingExplicitStarts() throws Exception { + byte[] backupData = randomData(BACKUP_DATA_SIZE_BYTES); + createEncryptedFileUsingExplicitStarts( + backupData, + chunkOrdering -> chunkOrdering, + chunksMetadata -> { + ChunksMetadata metadata = CryptoTestUtils.clone(chunksMetadata); + metadata.chunkOrderingType = + ChunksMetadataProto.CHUNK_ORDERING_TYPE_UNSPECIFIED; + return metadata; + }); + + mTask.decryptFile(mInputFile, mFileOutput); + + assertThat(Files.readAllBytes(Paths.get(mOutputFile.toURI()))).isEqualTo(backupData); + } + + @Test + public void decryptFile_withInlineLengths_throwsForZeroLengths() throws Exception { + createEncryptedFileUsingInlineLengths( + TEST_KV_DATA, chunkOrdering -> chunkOrdering, chunksMetadata -> chunksMetadata); + + // Set the length of the first chunk to zero. + RandomAccessFile raf = new RandomAccessFile(mInputFile, READ_WRITE_MODE); + raf.seek(0); + raf.writeInt(0); + + assertThrows( + MalformedEncryptedFileException.class, + () -> mTask.decryptFile(mInputFile, mKvOutput)); + } + + @Test + public void decryptFile_withInlineLengths_throwsForLongLengths() throws Exception { + createEncryptedFileUsingInlineLengths( + TEST_KV_DATA, chunkOrdering -> chunkOrdering, chunksMetadata -> chunksMetadata); + + // Set the length of the first chunk to zero. + RandomAccessFile raf = new RandomAccessFile(mInputFile, READ_WRITE_MODE); + raf.seek(0); + raf.writeInt((int) mInputFile.length()); + + assertThrows( + MalformedEncryptedFileException.class, + () -> mTask.decryptFile(mInputFile, mKvOutput)); + } + + @Test + public void decryptFile_throwsForBadKey() throws Exception { + createEncryptedFileUsingExplicitStarts(randomData(BACKUP_DATA_SIZE_BYTES)); + + assertThrows( + AEADBadTagException.class, + () -> + new BackupFileDecryptorTask(generateAesKey()) + .decryptFile(mInputFile, mFileOutput)); + } + + @Test + public void decryptFile_withExplicitStarts_throwsForMangledOrdering() throws Exception { + createEncryptedFileUsingExplicitStarts( + randomData(BACKUP_DATA_SIZE_BYTES), + chunkOrdering -> { + ChunkOrdering ordering = CryptoTestUtils.clone(chunkOrdering); + Arrays.sort(ordering.starts); + return ordering; + }); + + assertThrows( + MessageDigestMismatchException.class, + () -> mTask.decryptFile(mInputFile, mFileOutput)); + } + + @Test + public void decryptFile_withExplicitStarts_noChunks_returnsNoData() throws Exception { + byte[] backupData = randomData(/*length=*/ 0); + createEncryptedFileUsingExplicitStarts( + backupData, + chunkOrdering -> { + ChunkOrdering ordering = CryptoTestUtils.clone(chunkOrdering); + ordering.starts = new int[0]; + return ordering; + }); + + mTask.decryptFile(mInputFile, mFileOutput); + + assertThat(Files.readAllBytes(Paths.get(mOutputFile.toURI()))).isEqualTo(backupData); + } + + @Test + public void decryptFile_throwsForMismatchedChecksum() throws Exception { + createEncryptedFileUsingExplicitStarts( + randomData(BACKUP_DATA_SIZE_BYTES), + chunkOrdering -> { + ChunkOrdering ordering = CryptoTestUtils.clone(chunkOrdering); + ordering.checksum = + Arrays.copyOf(randomData(CHECKSUM_LENGTH_BYTES), CHECKSUM_LENGTH_BYTES); + return ordering; + }); + + assertThrows( + MessageDigestMismatchException.class, + () -> mTask.decryptFile(mInputFile, mFileOutput)); + } + + @Test + public void decryptFile_throwsForBadChunksMetadataOffset() throws Exception { + createEncryptedFileUsingExplicitStarts(randomData(BACKUP_DATA_SIZE_BYTES)); + + // Replace the metadata with all 1s. + RandomAccessFile raf = new RandomAccessFile(mInputFile, READ_WRITE_MODE); + raf.seek(raf.length() - Long.BYTES); + int metadataOffset = (int) raf.readLong(); + int metadataLength = (int) raf.length() - metadataOffset - Long.BYTES; + + byte[] allOnes = new byte[metadataLength]; + Arrays.fill(allOnes, (byte) 1); + + raf.seek(metadataOffset); + raf.write(allOnes, /*off=*/ 0, metadataLength); + + MalformedEncryptedFileException thrown = + expectThrows( + MalformedEncryptedFileException.class, + () -> mTask.decryptFile(mInputFile, mFileOutput)); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Could not read chunks metadata at position " + + metadataOffset + + " of file of " + + raf.length() + + " bytes"); + } + + @Test + public void decryptFile_throwsForChunksMetadataOffsetBeyondEndOfFile() throws Exception { + createEncryptedFileUsingExplicitStarts(randomData(BACKUP_DATA_SIZE_BYTES)); + + RandomAccessFile raf = new RandomAccessFile(mInputFile, READ_WRITE_MODE); + raf.seek(raf.length() - Long.BYTES); + raf.writeLong(raf.length()); + + MalformedEncryptedFileException thrown = + expectThrows( + MalformedEncryptedFileException.class, + () -> mTask.decryptFile(mInputFile, mFileOutput)); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + raf.length() + + " is not valid position for chunks metadata in file of " + + raf.length() + + " bytes"); + } + + @Test + public void decryptFile_throwsForChunksMetadataOffsetBeforeBeginningOfFile() throws Exception { + createEncryptedFileUsingExplicitStarts(randomData(BACKUP_DATA_SIZE_BYTES)); + + RandomAccessFile raf = new RandomAccessFile(mInputFile, READ_WRITE_MODE); + raf.seek(raf.length() - Long.BYTES); + raf.writeLong(-1); + + MalformedEncryptedFileException thrown = + expectThrows( + MalformedEncryptedFileException.class, + () -> mTask.decryptFile(mInputFile, mFileOutput)); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "-1 is not valid position for chunks metadata in file of " + + raf.length() + + " bytes"); + } + + @Test + public void decryptFile_throwsForMangledChunks() throws Exception { + createEncryptedFileUsingExplicitStarts(randomData(BACKUP_DATA_SIZE_BYTES)); + + // Mess up some bits in a random byte + RandomAccessFile raf = new RandomAccessFile(mInputFile, READ_WRITE_MODE); + raf.seek(50); + byte fiftiethByte = raf.readByte(); + raf.seek(50); + raf.write(~fiftiethByte); + + assertThrows(AEADBadTagException.class, () -> mTask.decryptFile(mInputFile, mFileOutput)); + } + + @Test + public void decryptFile_throwsForBadChunkEncryptionKey() throws Exception { + mChunkEncryptionKey = generateAesKey(); + + createEncryptedFileUsingExplicitStarts(randomData(BACKUP_DATA_SIZE_BYTES)); + + assertThrows(AEADBadTagException.class, () -> mTask.decryptFile(mInputFile, mFileOutput)); + } + + @Test + public void decryptFile_throwsForUnsupportedCipherType() throws Exception { + createEncryptedFileUsingExplicitStarts( + randomData(BACKUP_DATA_SIZE_BYTES), + chunkOrdering -> chunkOrdering, + chunksMetadata -> { + ChunksMetadata metadata = CryptoTestUtils.clone(chunksMetadata); + metadata.cipherType = ChunksMetadataProto.UNKNOWN_CIPHER_TYPE; + return metadata; + }); + + assertThrows( + UnsupportedEncryptedFileException.class, + () -> mTask.decryptFile(mInputFile, mFileOutput)); + } + + @Test + public void decryptFile_throwsForUnsupportedMessageDigestType() throws Exception { + createEncryptedFileUsingExplicitStarts( + randomData(BACKUP_DATA_SIZE_BYTES), + chunkOrdering -> chunkOrdering, + chunksMetadata -> { + ChunksMetadata metadata = CryptoTestUtils.clone(chunksMetadata); + metadata.checksumType = ChunksMetadataProto.UNKNOWN_CHECKSUM_TYPE; + return metadata; + }); + + assertThrows( + UnsupportedEncryptedFileException.class, + () -> mTask.decryptFile(mInputFile, mFileOutput)); + } + + /** + * Creates an encrypted backup file from the given data. + * + * @param data The plaintext content. + */ + private void createEncryptedFileUsingExplicitStarts(byte[] data) throws Exception { + createEncryptedFileUsingExplicitStarts(data, chunkOrdering -> chunkOrdering); + } + + /** + * Creates an encrypted backup file from the given data. + * + * @param data The plaintext content. + * @param chunkOrderingTransformer Transforms the ordering before it's encrypted. + */ + private void createEncryptedFileUsingExplicitStarts( + byte[] data, Transformer<ChunkOrdering> chunkOrderingTransformer) throws Exception { + createEncryptedFileUsingExplicitStarts( + data, chunkOrderingTransformer, chunksMetadata -> chunksMetadata); + } + + /** + * Creates an encrypted backup file from the given data in mode {@link + * ChunksMetadataProto#EXPLICIT_STARTS}. + * + * @param data The plaintext content. + * @param chunkOrderingTransformer Transforms the ordering before it's encrypted. + * @param chunksMetadataTransformer Transforms the metadata before it's written. + */ + private void createEncryptedFileUsingExplicitStarts( + byte[] data, + Transformer<ChunkOrdering> chunkOrderingTransformer, + Transformer<ChunksMetadata> chunksMetadataTransformer) + throws Exception { + Result result = backupFullData(data); + + ArrayList<EncryptedChunk> chunks = new ArrayList<>(result.getNewChunks()); + Collections.shuffle(chunks); + HashMap<ChunkHash, Integer> startPositions = new HashMap<>(); + + try (FileOutputStream fos = new FileOutputStream(mInputFile); + DataOutputStream dos = new DataOutputStream(fos)) { + int position = 0; + + for (EncryptedChunk chunk : chunks) { + startPositions.put(chunk.key(), position); + dos.write(chunk.nonce()); + dos.write(chunk.encryptedBytes()); + position += chunk.nonce().length + chunk.encryptedBytes().length; + } + + int[] starts = new int[chunks.size()]; + List<ChunkHash> chunkListing = result.getAllChunks(); + + for (int i = 0; i < chunks.size(); i++) { + starts[i] = startPositions.get(chunkListing.get(i)); + } + + ChunkOrdering chunkOrdering = newChunkOrdering(starts, result.getDigest()); + chunkOrdering = chunkOrderingTransformer.accept(chunkOrdering); + + ChunksMetadata metadata = + newChunksMetadata( + ChunksMetadataProto.AES_256_GCM, + ChunksMetadataProto.SHA_256, + ChunksMetadataProto.EXPLICIT_STARTS, + encrypt(chunkOrdering)); + metadata = chunksMetadataTransformer.accept(metadata); + + dos.write(MessageNano.toByteArray(metadata)); + dos.writeLong(position); + } + } + + /** + * Creates an encrypted backup file from the given data in mode {@link + * ChunksMetadataProto#INLINE_LENGTHS}. + * + * @param data The plaintext key value pairs to back up. + * @param chunkOrderingTransformer Transforms the ordering before it's encrypted. + * @param chunksMetadataTransformer Transforms the metadata before it's written. + */ + private void createEncryptedFileUsingInlineLengths( + Set<KeyValuePair> data, + Transformer<ChunkOrdering> chunkOrderingTransformer, + Transformer<ChunksMetadata> chunksMetadataTransformer) + throws Exception { + Result result = backupKvData(data); + + List<EncryptedChunk> chunks = new ArrayList<>(result.getNewChunks()); + System.out.println("we have chunk count " + chunks.size()); + Collections.shuffle(chunks); + + try (FileOutputStream fos = new FileOutputStream(mInputFile); + DataOutputStream dos = new DataOutputStream(fos)) { + for (EncryptedChunk chunk : chunks) { + dos.writeInt(chunk.nonce().length + chunk.encryptedBytes().length); + dos.write(chunk.nonce()); + dos.write(chunk.encryptedBytes()); + } + + ChunkOrdering chunkOrdering = newChunkOrdering(null, result.getDigest()); + chunkOrdering = chunkOrderingTransformer.accept(chunkOrdering); + + ChunksMetadata metadata = + newChunksMetadata( + ChunksMetadataProto.AES_256_GCM, + ChunksMetadataProto.SHA_256, + ChunksMetadataProto.INLINE_LENGTHS, + encrypt(chunkOrdering)); + metadata = chunksMetadataTransformer.accept(metadata); + + int metadataStart = dos.size(); + dos.write(MessageNano.toByteArray(metadata)); + dos.writeLong(metadataStart); + } + } + + /** Performs a full backup of the given data, and returns the chunks. */ + private BackupEncrypter.Result backupFullData(byte[] data) throws Exception { + BackupStreamEncrypter encrypter = + new BackupStreamEncrypter( + new ByteArrayInputStream(data), + MIN_CHUNK_SIZE_BYTES, + MAX_CHUNK_SIZE_BYTES, + AVERAGE_CHUNK_SIZE_BYTES); + return encrypter.backup( + mChunkEncryptionKey, + randomData(FingerprintMixer.SALT_LENGTH_BYTES), + new HashSet<>()); + } + + private Result backupKvData(Set<KeyValuePair> data) throws Exception { + ShadowBackupDataInput.reset(); + for (KeyValuePair pair : data) { + ShadowBackupDataInput.addEntity(pair.key, pair.value); + } + KvBackupEncrypter encrypter = + new KvBackupEncrypter(new BackupDataInput(NULL_FILE_DESCRIPTOR)); + return encrypter.backup( + mChunkEncryptionKey, + randomData(FingerprintMixer.SALT_LENGTH_BYTES), + Collections.EMPTY_SET); + } + + /** Encrypts {@code chunkOrdering} using {@link #mTertiaryKey}. */ + private byte[] encrypt(ChunkOrdering chunkOrdering) throws Exception { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + byte[] nonce = randomData(GCM_NONCE_LENGTH_BYTES); + cipher.init( + Cipher.ENCRYPT_MODE, + mTertiaryKey, + new GCMParameterSpec(GCM_TAG_LENGTH_BYTES * BITS_PER_BYTE, nonce)); + byte[] nanoBytes = MessageNano.toByteArray(chunkOrdering); + byte[] encryptedBytes = cipher.doFinal(nanoBytes); + + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + out.write(nonce); + out.write(encryptedBytes); + return out.toByteArray(); + } + } + + /** Returns {@code length} random bytes. */ + private byte[] randomData(int length) { + byte[] data = new byte[length]; + mRandom.nextBytes(data); + return data; + } + + private static ImmutableMap<String, String> asMap(Collection<KeyValuePair> pairs) { + ImmutableMap.Builder<String, String> map = ImmutableMap.builder(); + for (KeyValuePair pair : pairs) { + map.put(pair.key, new String(pair.value, Charset.forName("UTF-8"))); + } + return map.build(); + } + + private interface Transformer<T> { + T accept(T t); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java index b9055cecd502..5cff53f817d4 100644 --- a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java @@ -18,7 +18,9 @@ package com.android.server.backup.testing; import com.android.server.backup.encryption.chunk.ChunkHash; import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto; +import com.android.server.backup.encryption.protos.nano.KeyValuePairProto; +import java.nio.charset.Charset; import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Random; @@ -86,11 +88,33 @@ public class CryptoTestUtils { public static ChunksMetadataProto.ChunkOrdering newChunkOrdering( int[] starts, byte[] checksum) { ChunksMetadataProto.ChunkOrdering chunkOrdering = new ChunksMetadataProto.ChunkOrdering(); - chunkOrdering.starts = Arrays.copyOf(starts, starts.length); - chunkOrdering.checksum = Arrays.copyOf(checksum, checksum.length); + chunkOrdering.starts = starts == null ? null : Arrays.copyOf(starts, starts.length); + chunkOrdering.checksum = + checksum == null ? checksum : Arrays.copyOf(checksum, checksum.length); return chunkOrdering; } + public static ChunksMetadataProto.ChunksMetadata newChunksMetadata( + int cipherType, int checksumType, int chunkOrderingType, byte[] chunkOrdering) { + ChunksMetadataProto.ChunksMetadata metadata = new ChunksMetadataProto.ChunksMetadata(); + metadata.cipherType = cipherType; + metadata.checksumType = checksumType; + metadata.chunkOrdering = Arrays.copyOf(chunkOrdering, chunkOrdering.length); + metadata.chunkOrderingType = chunkOrderingType; + return metadata; + } + + public static KeyValuePairProto.KeyValuePair newPair(String key, String value) { + return newPair(key, value.getBytes(Charset.forName("UTF-8"))); + } + + public static KeyValuePairProto.KeyValuePair newPair(String key, byte[] value) { + KeyValuePairProto.KeyValuePair newPair = new KeyValuePairProto.KeyValuePair(); + newPair.key = key; + newPair.value = value; + return newPair; + } + public static ChunksMetadataProto.ChunkListing clone( ChunksMetadataProto.ChunkListing original) { ChunksMetadataProto.Chunk[] clonedChunks; @@ -114,4 +138,25 @@ public class CryptoTestUtils { public static ChunksMetadataProto.Chunk clone(ChunksMetadataProto.Chunk original) { return newChunk(original.hash, original.length); } + + public static ChunksMetadataProto.ChunksMetadata clone( + ChunksMetadataProto.ChunksMetadata original) { + ChunksMetadataProto.ChunksMetadata cloneMetadata = new ChunksMetadataProto.ChunksMetadata(); + cloneMetadata.chunkOrderingType = original.chunkOrderingType; + cloneMetadata.chunkOrdering = + original.chunkOrdering == null + ? null + : Arrays.copyOf(original.chunkOrdering, original.chunkOrdering.length); + cloneMetadata.checksumType = original.checksumType; + cloneMetadata.cipherType = original.cipherType; + return cloneMetadata; + } + + public static ChunksMetadataProto.ChunkOrdering clone( + ChunksMetadataProto.ChunkOrdering original) { + ChunksMetadataProto.ChunkOrdering clone = new ChunksMetadataProto.ChunkOrdering(); + clone.starts = Arrays.copyOf(original.starts, original.starts.length); + clone.checksum = Arrays.copyOf(original.checksum, original.checksum.length); + return clone; + } } |