Import BackupFileDecryptorTask

Bug: 111386661
Test: make RunBackupEncryptionRoboTests
Change-Id: I411ab1055203b6963726c9ca171b47b52cac83c8
This commit is contained in:
Al Sutton
2019-09-23 14:51:30 +01:00
parent 4ec9aa4b4e
commit cf327cddf9
6 changed files with 1087 additions and 2 deletions

View File

@@ -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));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}