diff options
Diffstat (limited to 'packages/BackupEncryption')
88 files changed, 8742 insertions, 0 deletions
diff --git a/packages/BackupEncryption/Android.bp b/packages/BackupEncryption/Android.bp new file mode 100644 index 000000000000..9bcd677538eb --- /dev/null +++ b/packages/BackupEncryption/Android.bp @@ -0,0 +1,31 @@ +// +// 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. +// + +android_app { + name: "BackupEncryption", + srcs: ["src/**/*.java"], + libs: ["backup-encryption-protos"], + optimize: { enabled: false }, + platform_apis: true, + certificate: "platform", + privileged: true, +} + +java_library { + name: "backup-encryption-protos", + proto: { type: "nano" }, + srcs: ["proto/**/*.proto"], +} diff --git a/packages/BackupEncryption/AndroidManifest.xml b/packages/BackupEncryption/AndroidManifest.xml new file mode 100644 index 000000000000..a705df5a425b --- /dev/null +++ b/packages/BackupEncryption/AndroidManifest.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* + * Copyright (c) 2016 Google Inc. + * + * 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. + */ +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.server.backup.encryption" + android:sharedUserId="android.uid.system" > + + <application android:allowBackup="false" /> +</manifest> diff --git a/packages/BackupEncryption/proguard.flags b/packages/BackupEncryption/proguard.flags new file mode 100644 index 000000000000..851ce8caab43 --- /dev/null +++ b/packages/BackupEncryption/proguard.flags @@ -0,0 +1 @@ +-keep class com.android.server.backup.encryption diff --git a/packages/BackupEncryption/proto/wrapped_key.proto b/packages/BackupEncryption/proto/wrapped_key.proto new file mode 100644 index 000000000000..817b7b40d606 --- /dev/null +++ b/packages/BackupEncryption/proto/wrapped_key.proto @@ -0,0 +1,52 @@ +syntax = "proto2"; + +package android_backup_crypto; + +option java_package = "com.android.server.backup.encryption.protos"; +option java_outer_classname = "WrappedKeyProto"; + +// Metadata associated with a tertiary key. +message KeyMetadata { + // Type of Cipher algorithm the key is used for. + enum Type { + UNKNOWN = 0; + // No padding. Uses 12-byte nonce. Tag length 16 bytes. + AES_256_GCM = 1; + } + + // What kind of Cipher algorithm the key is used for. We assume at the moment + // that this will always be AES_256_GCM and throw if this is not the case. + // Provided here for forwards compatibility in case at some point we need to + // change Cipher algorithm. + optional Type type = 1; +} + +// An encrypted tertiary key. +message WrappedKey { + // The Cipher with which the key was encrypted. + enum WrapAlgorithm { + UNKNOWN = 0; + // No padding. Uses 16-byte nonce (see nonce field). Tag length 16 bytes. + // The nonce is 16-bytes as this is wrapped with a key in AndroidKeyStore. + // AndroidKeyStore requires that it generates the IV, and it generates a + // 16-byte IV for you. You CANNOT provide your own IV. + AES_256_GCM = 1; + } + + // Cipher algorithm used to wrap the key. We assume at the moment that this + // is always AES_256_GC and throw if this is not the case. Provided here for + // forwards compatibility if at some point we need to change Cipher algorithm. + optional WrapAlgorithm wrap_algorithm = 1; + + // The nonce used to initialize the Cipher in AES/256/GCM mode. + optional bytes nonce = 2; + + // The encrypted bytes of the key material. + optional bytes key = 3; + + // Associated key metadata. + optional KeyMetadata metadata = 4; + + // Deprecated field; Do not use + reserved 5; +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/CryptoSettings.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/CryptoSettings.java new file mode 100644 index 000000000000..2010620f76ed --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/CryptoSettings.java @@ -0,0 +1,233 @@ +/* + * 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; + +import static com.android.internal.util.Preconditions.checkNotNull; +import static com.android.internal.util.Preconditions.checkState; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.security.keystore.recovery.InternalRecoveryServiceException; +import android.security.keystore.recovery.RecoveryController; +import android.util.Slog; + +import com.android.internal.annotations.VisibleForTesting; + +import java.security.KeyStoreException; +import java.util.Optional; + +/** + * State about encrypted backups that needs to be remembered. + */ +public class CryptoSettings { + + private static final String TAG = "CryptoSettings"; + + private static final String SHARED_PREFERENCES_NAME = "crypto_settings"; + + private static final String KEY_IS_INITIALIZED = "isInitialized"; + private static final String KEY_ACTIVE_SECONDARY_ALIAS = "activeSecondary"; + private static final String KEY_NEXT_SECONDARY_ALIAS = "nextSecondary"; + private static final String SECONDARY_KEY_LAST_ROTATED_AT = "secondaryKeyLastRotatedAt"; + private static final String[] SETTINGS_FOR_BACKUP = { + KEY_IS_INITIALIZED, + KEY_ACTIVE_SECONDARY_ALIAS, + KEY_NEXT_SECONDARY_ALIAS, + SECONDARY_KEY_LAST_ROTATED_AT + }; + + private static final String KEY_ANCESTRAL_SECONDARY_KEY_VERSION = + "ancestral_secondary_key_version"; + + private final SharedPreferences mSharedPreferences; + private final Context mContext; + + /** + * A new instance. + * + * @param context For looking up the {@link SharedPreferences}, for storing state. + * @return The instance. + */ + public static CryptoSettings getInstance(Context context) { + // We need single process mode because CryptoSettings may be used from several processes + // simultaneously. + SharedPreferences sharedPreferences = + context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); + return new CryptoSettings(sharedPreferences, context); + } + + /** + * A new instance using {@link SharedPreferences} in the default mode. + * + * <p>This will not work across multiple processes but will work in tests. + */ + @VisibleForTesting + public static CryptoSettings getInstanceForTesting(Context context) { + SharedPreferences sharedPreferences = + context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); + return new CryptoSettings(sharedPreferences, context); + } + + private CryptoSettings(SharedPreferences sharedPreferences, Context context) { + mSharedPreferences = checkNotNull(sharedPreferences); + mContext = checkNotNull(context); + } + + /** + * The alias of the current active secondary key. This should be used to retrieve the key from + * AndroidKeyStore. + */ + public Optional<String> getActiveSecondaryKeyAlias() { + return getStringInSharedPrefs(KEY_ACTIVE_SECONDARY_ALIAS); + } + + /** + * The alias of the secondary key to which the client is rotating. The rotation is not + * immediate, which is why this setting is needed. Once the next key is created, it can take up + * to 72 hours potentially (or longer if the user has no network) for the next key to be synced + * with the keystore. Only after that has happened does the client attempt to re-wrap all + * tertiary keys and commit the rotation. + */ + public Optional<String> getNextSecondaryKeyAlias() { + return getStringInSharedPrefs(KEY_NEXT_SECONDARY_ALIAS); + } + + /** + * If the settings have been initialized. + */ + public boolean getIsInitialized() { + return mSharedPreferences.getBoolean(KEY_IS_INITIALIZED, false); + } + + /** + * Sets the alias of the currently active secondary key. + * + * @param activeAlias The alias, as in AndroidKeyStore. + * @throws IllegalArgumentException if the alias is not in the user's keystore. + */ + public void setActiveSecondaryKeyAlias(String activeAlias) throws IllegalArgumentException { + assertIsValidAlias(activeAlias); + mSharedPreferences.edit().putString(KEY_ACTIVE_SECONDARY_ALIAS, activeAlias).apply(); + } + + /** + * Sets the alias of the secondary key to which the client is rotating. + * + * @param nextAlias The alias, as in AndroidKeyStore. + * @throws KeyStoreException if unable to check whether alias is valid in the keystore. + * @throws IllegalArgumentException if the alias is not in the user's keystore. + */ + public void setNextSecondaryAlias(String nextAlias) throws IllegalArgumentException { + assertIsValidAlias(nextAlias); + mSharedPreferences.edit().putString(KEY_NEXT_SECONDARY_ALIAS, nextAlias).apply(); + } + + /** + * Unsets the alias of the key to which the client is rotating. This is generally performed once + * a rotation is complete. + */ + public void removeNextSecondaryKeyAlias() { + mSharedPreferences.edit().remove(KEY_NEXT_SECONDARY_ALIAS).apply(); + } + + /** + * Sets the timestamp of when the secondary key was last rotated. + * + * @param timestamp The timestamp to set. + */ + public void setSecondaryLastRotated(long timestamp) { + mSharedPreferences.edit().putLong(SECONDARY_KEY_LAST_ROTATED_AT, timestamp).apply(); + } + + /** + * Returns a timestamp of when the secondary key was last rotated. + * + * @return The timestamp. + */ + public Optional<Long> getSecondaryLastRotated() { + if (!mSharedPreferences.contains(SECONDARY_KEY_LAST_ROTATED_AT)) { + return Optional.empty(); + } + return Optional.of(mSharedPreferences.getLong(SECONDARY_KEY_LAST_ROTATED_AT, -1)); + } + + /** + * Sets the settings to have been initialized. (Otherwise loading should try to initialize + * again.) + */ + private void setIsInitialized() { + mSharedPreferences.edit().putBoolean(KEY_IS_INITIALIZED, true).apply(); + } + + /** + * Initializes with the given key alias. + * + * @param alias The secondary key alias to be set as active. + * @throws IllegalArgumentException if the alias does not reference a valid key. + * @throws IllegalStateException if attempting to initialize an already initialized settings. + */ + public void initializeWithKeyAlias(String alias) throws IllegalArgumentException { + checkState( + !getIsInitialized(), "Attempting to initialize an already initialized settings."); + setActiveSecondaryKeyAlias(alias); + setIsInitialized(); + } + + /** Returns the secondary key version of the encrypted backup set to restore from (if set). */ + public Optional<String> getAncestralSecondaryKeyVersion() { + return Optional.ofNullable( + mSharedPreferences.getString(KEY_ANCESTRAL_SECONDARY_KEY_VERSION, null)); + } + + /** Sets the secondary key version of the encrypted backup set to restore from. */ + public void setAncestralSecondaryKeyVersion(String ancestralSecondaryKeyVersion) { + mSharedPreferences + .edit() + .putString(KEY_ANCESTRAL_SECONDARY_KEY_VERSION, ancestralSecondaryKeyVersion) + .apply(); + } + + /** Deletes all crypto settings related to backup (as opposed to restore). */ + public void clearAllSettingsForBackup() { + Editor sharedPrefsEditor = mSharedPreferences.edit(); + for (String backupSettingKey : SETTINGS_FOR_BACKUP) { + sharedPrefsEditor.remove(backupSettingKey); + } + sharedPrefsEditor.apply(); + + Slog.d(TAG, "Cleared crypto settings for backup"); + } + + /** + * Throws {@link IllegalArgumentException} if the alias does not refer to a key that is in + * the {@link RecoveryController}. + */ + private void assertIsValidAlias(String alias) throws IllegalArgumentException { + try { + if (!RecoveryController.getInstance(mContext).getAliases().contains(alias)) { + throw new IllegalArgumentException(alias + " is not in RecoveryController"); + } + } catch (InternalRecoveryServiceException e) { + throw new IllegalArgumentException("Problem accessing recovery service", e); + } + } + + private Optional<String> getStringInSharedPrefs(String key) { + return Optional.ofNullable(mSharedPreferences.getString(key, null)); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/Chunk.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/Chunk.java new file mode 100644 index 000000000000..ba328609a77e --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/Chunk.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2018 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.chunk; + +import android.util.proto.ProtoInputStream; + +import java.io.IOException; + +/** + * Information about a chunk entry in a protobuf. Only used for reading from a {@link + * ProtoInputStream}. + */ +public class Chunk { + /** + * Reads a Chunk from a {@link ProtoInputStream}. Expects the message to be of format {@link + * ChunksMetadataProto.Chunk}. + * + * @param inputStream currently at a {@link ChunksMetadataProto.Chunk} message. + * @throws IOException when the message is not structured as expected or a field can not be + * read. + */ + static Chunk readFromProto(ProtoInputStream inputStream) throws IOException { + Chunk result = new Chunk(); + + while (inputStream.nextField() != ProtoInputStream.NO_MORE_FIELDS) { + switch (inputStream.getFieldNumber()) { + case (int) ChunksMetadataProto.Chunk.HASH: + result.mHash = inputStream.readBytes(ChunksMetadataProto.Chunk.HASH); + break; + case (int) ChunksMetadataProto.Chunk.LENGTH: + result.mLength = inputStream.readInt(ChunksMetadataProto.Chunk.LENGTH); + break; + } + } + + return result; + } + + private int mLength; + private byte[] mHash; + + /** Private constructor. This class should only be instantiated by calling readFromProto. */ + private Chunk() { + // Set default values for fields in case they are not available in the proto. + mHash = new byte[]{}; + mLength = 0; + } + + public int getLength() { + return mLength; + } + + public byte[] getHash() { + return mHash; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkHash.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkHash.java new file mode 100644 index 000000000000..1630eb8ff4e8 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkHash.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2018 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.chunk; + +import com.android.internal.util.Preconditions; + +import java.util.Arrays; +import java.util.Base64; + +/** + * Represents the SHA-256 hash of the plaintext of a chunk, which is frequently used as a key. + * + * <p>This class is {@link Comparable} and implements {@link #equals(Object)} and {@link + * #hashCode()}. + */ +public class ChunkHash implements Comparable<ChunkHash> { + /** The length of the hash in bytes. The hash is a SHA-256, so this is 256 bits. */ + public static final int HASH_LENGTH_BYTES = 256 / 8; + + private static final int UNSIGNED_MASK = 0xFF; + + private final byte[] mHash; + + /** Constructs a new instance which wraps the given SHA-256 hash bytes. */ + public ChunkHash(byte[] hash) { + Preconditions.checkArgument(hash.length == HASH_LENGTH_BYTES, "Hash must have 256 bits"); + mHash = hash; + } + + public byte[] getHash() { + return mHash; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ChunkHash)) { + return false; + } + + ChunkHash chunkHash = (ChunkHash) o; + return Arrays.equals(mHash, chunkHash.mHash); + } + + @Override + public int hashCode() { + return Arrays.hashCode(mHash); + } + + @Override + public int compareTo(ChunkHash other) { + return lexicographicalCompareUnsignedBytes(getHash(), other.getHash()); + } + + @Override + public String toString() { + return Base64.getEncoder().encodeToString(mHash); + } + + private static int lexicographicalCompareUnsignedBytes(byte[] left, byte[] right) { + int minLength = Math.min(left.length, right.length); + for (int i = 0; i < minLength; i++) { + int result = toInt(left[i]) - toInt(right[i]); + if (result != 0) { + return result; + } + } + return left.length - right.length; + } + + private static int toInt(byte value) { + return value & UNSIGNED_MASK; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkListingMap.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkListingMap.java new file mode 100644 index 000000000000..a44890118717 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkListingMap.java @@ -0,0 +1,109 @@ +/* + * 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.chunk; + +import android.annotation.Nullable; +import android.util.proto.ProtoInputStream; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Chunk listing in a format optimized for quick look-up of chunks via their hash keys. This is + * useful when building an incremental backup. After a chunk has been produced, the algorithm can + * quickly look up whether the chunk existed in the previous backup by checking this chunk listing. + * It can then tell the server to use that chunk, through telling it the position and length of the + * chunk in the previous backup's blob. + */ +public class ChunkListingMap { + /** + * Reads a ChunkListingMap from a {@link ProtoInputStream}. Expects the message to be of format + * {@link ChunksMetadataProto.ChunkListing}. + * + * @param inputStream Currently at a {@link ChunksMetadataProto.ChunkListing} message. + * @throws IOException when the message is not structured as expected or a field can not be + * read. + */ + public static ChunkListingMap readFromProto(ProtoInputStream inputStream) throws IOException { + Map<ChunkHash, Entry> entries = new HashMap(); + + long start = 0; + + while (inputStream.nextField() != ProtoInputStream.NO_MORE_FIELDS) { + if (inputStream.getFieldNumber() == (int) ChunksMetadataProto.ChunkListing.CHUNKS) { + long chunkToken = inputStream.start(ChunksMetadataProto.ChunkListing.CHUNKS); + Chunk chunk = Chunk.readFromProto(inputStream); + entries.put(new ChunkHash(chunk.getHash()), new Entry(start, chunk.getLength())); + start += chunk.getLength(); + inputStream.end(chunkToken); + } + } + + return new ChunkListingMap(entries); + } + + private final Map<ChunkHash, Entry> mChunksByHash; + + private ChunkListingMap(Map<ChunkHash, Entry> chunksByHash) { + mChunksByHash = Collections.unmodifiableMap(new HashMap<>(chunksByHash)); + } + + /** Returns {@code true} if there is a chunk with the given SHA-256 MAC key in the listing. */ + public boolean hasChunk(ChunkHash hash) { + return mChunksByHash.containsKey(hash); + } + + /** + * Returns the entry for the chunk with the given hash. + * + * @param hash The SHA-256 MAC of the plaintext of the chunk. + * @return The entry, containing position and length of the chunk in the backup blob, or null if + * it does not exist. + */ + @Nullable + public Entry getChunkEntry(ChunkHash hash) { + return mChunksByHash.get(hash); + } + + /** Returns the number of chunks in this listing. */ + public int getChunkCount() { + return mChunksByHash.size(); + } + + /** Information about a chunk entry in a backup blob - i.e., its position and length. */ + public static final class Entry { + private final int mLength; + private final long mStart; + + private Entry(long start, int length) { + mStart = start; + mLength = length; + } + + /** Returns the length of the chunk in bytes. */ + public int getLength() { + return mLength; + } + + /** Returns the start position of the chunk in the backup blob, in bytes. */ + public long getStart() { + return mStart; + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkOrderingType.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkOrderingType.java new file mode 100644 index 000000000000..8cb028e46e9d --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/ChunkOrderingType.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2018 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.chunk; + +import static com.android.server.backup.encryption.chunk.ChunksMetadataProto.CHUNK_ORDERING_TYPE_UNSPECIFIED; +import static com.android.server.backup.encryption.chunk.ChunksMetadataProto.EXPLICIT_STARTS; +import static com.android.server.backup.encryption.chunk.ChunksMetadataProto.INLINE_LENGTHS; + +import android.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** IntDef corresponding to the ChunkOrderingType enum in the ChunksMetadataProto protobuf. */ +@IntDef({CHUNK_ORDERING_TYPE_UNSPECIFIED, EXPLICIT_STARTS, INLINE_LENGTHS}) +@Retention(RetentionPolicy.SOURCE) +public @interface ChunkOrderingType {} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrdering.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrdering.java new file mode 100644 index 000000000000..edf1b9abb822 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrdering.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2018 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.chunk; + +import java.util.Arrays; + +/** + * Holds the bytes of an encrypted {@link ChunksMetadataProto.ChunkOrdering}. + * + * <p>TODO(b/116575321): After all code is ported, remove the factory method and rename + * encryptedChunkOrdering() to getBytes(). + */ +public class EncryptedChunkOrdering { + /** + * Constructs a new object holding the given bytes of an encrypted {@link + * ChunksMetadataProto.ChunkOrdering}. + * + * <p>Note that this just holds an ordering which is already encrypted, it does not encrypt the + * ordering. + */ + public static EncryptedChunkOrdering create(byte[] encryptedChunkOrdering) { + return new EncryptedChunkOrdering(encryptedChunkOrdering); + } + + private final byte[] mEncryptedChunkOrdering; + + /** Get the encrypted chunk ordering */ + public byte[] encryptedChunkOrdering() { + return mEncryptedChunkOrdering; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof EncryptedChunkOrdering)) { + return false; + } + + EncryptedChunkOrdering encryptedChunkOrdering = (EncryptedChunkOrdering) o; + return Arrays.equals( + mEncryptedChunkOrdering, encryptedChunkOrdering.mEncryptedChunkOrdering); + } + + @Override + public int hashCode() { + return Arrays.hashCode(mEncryptedChunkOrdering); + } + + private EncryptedChunkOrdering(byte[] encryptedChunkOrdering) { + mEncryptedChunkOrdering = encryptedChunkOrdering; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/BackupWriter.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/BackupWriter.java new file mode 100644 index 000000000000..baa820cbd558 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/BackupWriter.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2018 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.chunking; + +import java.io.IOException; + +/** Writes backup data either as a diff script or as raw data, determined by the implementation. */ +public interface BackupWriter { + /** Writes the given bytes to the output. */ + void writeBytes(byte[] bytes) throws IOException; + + /** + * Writes an existing chunk from the previous backup to the output. + * + * <p>Note: not all implementations support this method. + */ + void writeChunk(long start, int length) throws IOException; + + /** Returns the number of bytes written, included bytes copied from the old file. */ + long getBytesWritten(); + + /** + * Indicates that no more bytes or chunks will be written. + * + * <p>After calling this, you may not call {@link #writeBytes(byte[])} or {@link + * #writeChunk(long, int)} + */ + void flush() throws IOException; +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ByteRange.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ByteRange.java new file mode 100644 index 000000000000..004d9e3b45f1 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ByteRange.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.internal.util.Preconditions; + +/** Representation of a range of bytes to be downloaded. */ +final class ByteRange { + private final long mStart; + private final long mEnd; + + /** Creates a range of bytes which includes {@code mStart} and {@code mEnd}. */ + ByteRange(long start, long end) { + Preconditions.checkArgument(start >= 0); + Preconditions.checkArgument(end >= start); + mStart = start; + mEnd = end; + } + + /** Returns the start of the {@code ByteRange}. The start is included in the range. */ + long getStart() { + return mStart; + } + + /** Returns the end of the {@code ByteRange}. The end is included in the range. */ + long getEnd() { + return mEnd; + } + + /** Returns the number of bytes included in the {@code ByteRange}. */ + int getLength() { + return (int) (mEnd - mStart + 1); + } + + /** Creates a new {@link ByteRange} from {@code mStart} to {@code mEnd + length}. */ + ByteRange extend(long length) { + Preconditions.checkArgument(length > 0); + return new ByteRange(mStart, mEnd + length); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ByteRange)) { + return false; + } + + ByteRange byteRange = (ByteRange) o; + return (mEnd == byteRange.mEnd && mStart == byteRange.mStart); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (int) (mStart ^ (mStart >>> 32)); + result = 31 * result + (int) (mEnd ^ (mEnd >>> 32)); + return result; + } + + @Override + public String toString() { + return String.format("ByteRange{mStart=%d, mEnd=%d}", mStart, mEnd); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ChunkEncryptor.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ChunkEncryptor.java new file mode 100644 index 000000000000..48abc8cc4088 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ChunkEncryptor.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.server.backup.encryption.chunk.ChunkHash; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +/** Encrypts chunks of a file using AES/GCM. */ +public class ChunkEncryptor { + 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 final SecretKey mSecretKey; + private final SecureRandom mSecureRandom; + + /** + * A new instance using {@code mSecretKey} to encrypt chunks and {@code mSecureRandom} to + * generate nonces. + */ + public ChunkEncryptor(SecretKey secretKey, SecureRandom secureRandom) { + this.mSecretKey = secretKey; + this.mSecureRandom = secureRandom; + } + + /** + * Transforms {@code plaintext} into an {@link EncryptedChunk}. + * + * @param plaintextHash The hash of the plaintext to encrypt, to attach as the key of the chunk. + * @param plaintext Bytes to encrypt. + * @throws InvalidKeyException If the given secret key is not a valid AES key for decryption. + * @throws IllegalBlockSizeException If the input data cannot be encrypted using + * AES/GCM/NoPadding. This should never be the case. + */ + public EncryptedChunk encrypt(ChunkHash plaintextHash, byte[] plaintext) + throws InvalidKeyException, IllegalBlockSizeException { + byte[] nonce = generateNonce(); + Cipher cipher; + try { + cipher = Cipher.getInstance(CIPHER_ALGORITHM); + cipher.init( + Cipher.ENCRYPT_MODE, + mSecretKey, + new GCMParameterSpec(GCM_TAG_LENGTH_BYTES * 8, nonce)); + } catch (NoSuchAlgorithmException + | NoSuchPaddingException + | InvalidAlgorithmParameterException e) { + // This can not happen - AES/GCM/NoPadding is supported. + throw new AssertionError(e); + } + byte[] encryptedBytes; + try { + encryptedBytes = cipher.doFinal(plaintext); + } catch (BadPaddingException e) { + // This can not happen - BadPaddingException can only be thrown in decrypt mode. + throw new AssertionError("Impossible: threw BadPaddingException in encrypt mode."); + } + + return EncryptedChunk.create(/*key=*/ plaintextHash, nonce, encryptedBytes); + } + + private byte[] generateNonce() { + byte[] nonce = new byte[GCM_NONCE_LENGTH_BYTES]; + mSecureRandom.nextBytes(nonce); + return nonce; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ChunkHasher.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ChunkHasher.java new file mode 100644 index 000000000000..02d498ccd726 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ChunkHasher.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.server.backup.encryption.chunk.ChunkHash; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.SecretKey; + +/** Computes the SHA-256 HMAC of a chunk of bytes. */ +public class ChunkHasher { + private static final String MAC_ALGORITHM = "HmacSHA256"; + + private final SecretKey mSecretKey; + + /** Constructs a new hasher which computes the HMAC using the given secret key. */ + public ChunkHasher(SecretKey secretKey) { + this.mSecretKey = secretKey; + } + + /** Returns the SHA-256 over the given bytes. */ + public ChunkHash computeHash(byte[] plaintext) throws InvalidKeyException { + try { + Mac mac = Mac.getInstance(MAC_ALGORITHM); + mac.init(mSecretKey); + return new ChunkHash(mac.doFinal(plaintext)); + } catch (NoSuchAlgorithmException e) { + // This can not happen - AES/GCM/NoPadding is available as part of the framework. + throw new AssertionError(e); + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/Chunker.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/Chunker.java new file mode 100644 index 000000000000..c9a6293ed060 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/Chunker.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2018 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.chunking; + +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; + +/** Splits an input stream into chunks, which are to be encrypted separately. */ +public interface Chunker { + /** + * Splits the input stream into chunks. + * + * @param inputStream The input stream. + * @param chunkConsumer A function that processes each chunk as it is produced. + * @throws IOException If there is a problem reading the input stream. + * @throws GeneralSecurityException if the consumer function throws an error. + */ + void chunkify(InputStream inputStream, ChunkConsumer chunkConsumer) + throws IOException, GeneralSecurityException; + + /** Function that consumes chunks. */ + interface ChunkConsumer { + /** + * Invoked for each chunk. + * + * @param chunk Plaintext bytes of chunk. + * @throws GeneralSecurityException if there is an issue encrypting the chunk. + */ + void accept(byte[] chunk) throws GeneralSecurityException; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutput.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutput.java new file mode 100644 index 000000000000..ae2e150de4bc --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutput.java @@ -0,0 +1,87 @@ +/* + * 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.chunking; + +import static com.android.internal.util.Preconditions.checkState; + +import android.annotation.Nullable; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.backup.encryption.tasks.DecryptedChunkOutput; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** Writes plaintext chunks to a file, building a digest of the plaintext of the resulting file. */ +public class DecryptedChunkFileOutput implements DecryptedChunkOutput { + @VisibleForTesting static final String DIGEST_ALGORITHM = "SHA-256"; + + private final File mOutputFile; + private final MessageDigest mMessageDigest; + @Nullable private FileOutputStream mFileOutputStream; + private boolean mClosed; + @Nullable private byte[] mDigest; + + /** + * Constructs a new instance which writes chunks to the given file and uses the default message + * digest algorithm. + */ + public DecryptedChunkFileOutput(File outputFile) { + mOutputFile = outputFile; + try { + mMessageDigest = MessageDigest.getInstance(DIGEST_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError( + "Impossible condition: JCE thinks it does not support AES.", e); + } + } + + @Override + public DecryptedChunkOutput open() throws IOException { + checkState(mFileOutputStream == null, "Cannot open twice"); + mFileOutputStream = new FileOutputStream(mOutputFile); + return this; + } + + @Override + public void processChunk(byte[] plaintextBuffer, int length) throws IOException { + checkState(mFileOutputStream != null, "Must open before processing chunks"); + mFileOutputStream.write(plaintextBuffer, /*off=*/ 0, length); + mMessageDigest.update(plaintextBuffer, /*offset=*/ 0, length); + } + + @Override + public byte[] getDigest() { + checkState(mClosed, "Must close before getting mDigest"); + + // After the first call to mDigest() the MessageDigest is reset, thus we must store the + // result. + if (mDigest == null) { + mDigest = mMessageDigest.digest(); + } + return mDigest; + } + + @Override + public void close() throws IOException { + mFileOutputStream.close(); + mClosed = true; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriter.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriter.java new file mode 100644 index 000000000000..69fb5cbf606d --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriter.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.IOException; +import java.io.OutputStream; + +/** Writes backup data to a diff script, using a {@link SingleStreamDiffScriptWriter}. */ +public class DiffScriptBackupWriter implements BackupWriter { + /** + * The maximum size of a chunk in the diff script. The diff script writer {@code mWriter} will + * buffer this many bytes in memory. + */ + private static final int ENCRYPTION_DIFF_SCRIPT_MAX_CHUNK_SIZE_BYTES = 1024 * 1024; + + private final SingleStreamDiffScriptWriter mWriter; + private long mBytesWritten; + + /** + * Constructs a new writer which writes the diff script to the given output stream, using the + * maximum new chunk size {@code ENCRYPTION_DIFF_SCRIPT_MAX_CHUNK_SIZE_BYTES}. + */ + public static DiffScriptBackupWriter newInstance(OutputStream outputStream) { + SingleStreamDiffScriptWriter writer = + new SingleStreamDiffScriptWriter( + outputStream, ENCRYPTION_DIFF_SCRIPT_MAX_CHUNK_SIZE_BYTES); + return new DiffScriptBackupWriter(writer); + } + + @VisibleForTesting + DiffScriptBackupWriter(SingleStreamDiffScriptWriter writer) { + mWriter = writer; + } + + @Override + public void writeBytes(byte[] bytes) throws IOException { + for (byte b : bytes) { + mWriter.writeByte(b); + } + + mBytesWritten += bytes.length; + } + + @Override + public void writeChunk(long start, int length) throws IOException { + mWriter.writeChunk(start, length); + mBytesWritten += length; + } + + @Override + public long getBytesWritten() { + return mBytesWritten; + } + + @Override + public void flush() throws IOException { + mWriter.flush(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DiffScriptWriter.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DiffScriptWriter.java new file mode 100644 index 000000000000..49d15712d4cc --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/DiffScriptWriter.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2018 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.chunking; + +import java.io.IOException; +import java.io.OutputStream; + +/** Writer that formats a Diff Script and writes it to an output source. */ +interface DiffScriptWriter { + /** Adds a new byte to the diff script. */ + void writeByte(byte b) throws IOException; + + /** Adds a known chunk to the diff script. */ + void writeChunk(long chunkStart, int chunkLength) throws IOException; + + /** Indicates that no more bytes or chunks will be added to the diff script. */ + void flush() throws IOException; + + interface Factory { + DiffScriptWriter create(OutputStream outputStream); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/EncryptedChunk.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/EncryptedChunk.java new file mode 100644 index 000000000000..cde59fa189de --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/EncryptedChunk.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.internal.util.Preconditions; +import com.android.server.backup.encryption.chunk.ChunkHash; + +import java.util.Arrays; +import java.util.Objects; + +/** + * A chunk of a file encrypted using AES/GCM. + * + * <p>TODO(b/116575321): After all code is ported, remove the factory method and rename + * encryptedBytes(), key() and nonce(). + */ +public class EncryptedChunk { + public static final int KEY_LENGTH_BYTES = ChunkHash.HASH_LENGTH_BYTES; + public static final int NONCE_LENGTH_BYTES = 12; + + /** + * Constructs a new instance with the given key, nonce, and encrypted bytes. + * + * @param key SHA-256 Hmac of the chunk plaintext. + * @param nonce Nonce with which the bytes of the chunk were encrypted. + * @param encryptedBytes Encrypted bytes of the chunk. + */ + public static EncryptedChunk create(ChunkHash key, byte[] nonce, byte[] encryptedBytes) { + Preconditions.checkArgument( + nonce.length == NONCE_LENGTH_BYTES, "Nonce does not have the correct length."); + return new EncryptedChunk(key, nonce, encryptedBytes); + } + + private ChunkHash mKey; + private byte[] mNonce; + private byte[] mEncryptedBytes; + + private EncryptedChunk(ChunkHash key, byte[] nonce, byte[] encryptedBytes) { + mKey = key; + mNonce = nonce; + mEncryptedBytes = encryptedBytes; + } + + /** The SHA-256 Hmac of the plaintext bytes of the chunk. */ + public ChunkHash key() { + return mKey; + } + + /** The nonce with which the chunk was encrypted. */ + public byte[] nonce() { + return mNonce; + } + + /** The encrypted bytes of the chunk. */ + public byte[] encryptedBytes() { + return mEncryptedBytes; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof EncryptedChunk)) { + return false; + } + + EncryptedChunk encryptedChunkOrdering = (EncryptedChunk) o; + return Arrays.equals(mEncryptedBytes, encryptedChunkOrdering.mEncryptedBytes) + && Arrays.equals(mNonce, encryptedChunkOrdering.mNonce) + && mKey.equals(encryptedChunkOrdering.mKey); + } + + @Override + public int hashCode() { + return Objects.hash(mKey, Arrays.hashCode(mNonce), Arrays.hashCode(mEncryptedBytes)); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/EncryptedChunkEncoder.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/EncryptedChunkEncoder.java new file mode 100644 index 000000000000..16beda32af17 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/EncryptedChunkEncoder.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.server.backup.encryption.chunk.ChunkOrderingType; + +import java.io.IOException; + +/** Encodes an {@link EncryptedChunk} as bytes to write to the encrypted backup file. */ +public interface EncryptedChunkEncoder { + /** + * Encodes the given chunk and asks the writer to write it. + * + * <p>The chunk will be encoded in the format [nonce]+[encrypted data]. + * + * <p>TODO(b/116575321): Choose a more descriptive method name after the code move is done. + */ + void writeChunkToWriter(BackupWriter writer, EncryptedChunk chunk) throws IOException; + + /** + * Returns the length in bytes that this chunk would be if encoded with {@link + * #writeChunkToWriter}. + */ + int getEncodedLengthOfChunk(EncryptedChunk chunk); + + /** + * Returns the {@link ChunkOrderingType} that must be included in the backup file, when using + * this decoder, so that the file may be correctly decoded. + */ + @ChunkOrderingType + int getChunkOrderingType(); +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoder.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoder.java new file mode 100644 index 000000000000..7b38dd4a1dc3 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoder.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.server.backup.encryption.chunk.ChunkOrderingType; +import com.android.server.backup.encryption.chunk.ChunksMetadataProto; + +import java.io.IOException; + +/** + * Encodes an {@link EncryptedChunk} as bytes, prepending the length of the chunk. + * + * <p>This allows us to decode the backup file during restore without any extra information about + * the boundaries of the chunks. The backup file should contain a chunk ordering in mode {@link + * ChunksMetadataProto#INLINE_LENGTHS}. + * + * <p>We use this implementation during key value backup. + */ +public class InlineLengthsEncryptedChunkEncoder implements EncryptedChunkEncoder { + public static final int BYTES_LENGTH = Integer.SIZE / Byte.SIZE; + + private final LengthlessEncryptedChunkEncoder mLengthlessEncryptedChunkEncoder = + new LengthlessEncryptedChunkEncoder(); + + @Override + public void writeChunkToWriter(BackupWriter writer, EncryptedChunk chunk) throws IOException { + int length = mLengthlessEncryptedChunkEncoder.getEncodedLengthOfChunk(chunk); + writer.writeBytes(toByteArray(length)); + mLengthlessEncryptedChunkEncoder.writeChunkToWriter(writer, chunk); + } + + @Override + public int getEncodedLengthOfChunk(EncryptedChunk chunk) { + return BYTES_LENGTH + mLengthlessEncryptedChunkEncoder.getEncodedLengthOfChunk(chunk); + } + + @Override + @ChunkOrderingType + public int getChunkOrderingType() { + return ChunksMetadataProto.INLINE_LENGTHS; + } + + /** + * Returns a big-endian representation of {@code value} in a 4-element byte array; equivalent to + * {@code ByteBuffer.allocate(4).putInt(value).array()}. For example, the input value {@code + * 0x12131415} would yield the byte array {@code {0x12, 0x13, 0x14, 0x15}}. + * + * <p>Equivalent to guava's Ints.toByteArray. + */ + static byte[] toByteArray(int value) { + return new byte[] { + (byte) (value >> 24), (byte) (value >> 16), (byte) (value >> 8), (byte) value + }; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoder.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoder.java new file mode 100644 index 000000000000..567f75d59513 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoder.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2018 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.chunking; + +import com.android.server.backup.encryption.chunk.ChunkOrderingType; +import com.android.server.backup.encryption.chunk.ChunksMetadataProto; + +import java.io.IOException; + +/** + * Encodes an {@link EncryptedChunk} as bytes without including any information about the length of + * the chunk. + * + * <p>In order for us to decode the backup file during restore it must include a chunk ordering in + * mode {@link ChunksMetadataProto#EXPLICIT_STARTS}, which contains the boundaries of the chunks in + * the encrypted file. This information allows us to decode the backup file and divide it into + * chunks without including the length of each chunk inline. + * + * <p>We use this implementation during full backup. + */ +public class LengthlessEncryptedChunkEncoder implements EncryptedChunkEncoder { + @Override + public void writeChunkToWriter(BackupWriter writer, EncryptedChunk chunk) throws IOException { + writer.writeBytes(chunk.nonce()); + writer.writeBytes(chunk.encryptedBytes()); + } + + @Override + public int getEncodedLengthOfChunk(EncryptedChunk chunk) { + return chunk.nonce().length + chunk.encryptedBytes().length; + } + + @Override + @ChunkOrderingType + public int getChunkOrderingType() { + return ChunksMetadataProto.EXPLICIT_STARTS; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/OutputStreamWrapper.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/OutputStreamWrapper.java new file mode 100644 index 000000000000..4aea60121810 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/OutputStreamWrapper.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2018 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.chunking; + +import java.io.OutputStream; + +/** An interface that wraps one {@link OutputStream} with another for filtration purposes. */ +public interface OutputStreamWrapper { + /** Wraps a given {@link OutputStream}. */ + OutputStream wrap(OutputStream outputStream); +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/RawBackupWriter.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/RawBackupWriter.java new file mode 100644 index 000000000000..b211b0fc9470 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/RawBackupWriter.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2018 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.chunking; + +import java.io.IOException; +import java.io.OutputStream; + +/** Writes data straight to an output stream. */ +public class RawBackupWriter implements BackupWriter { + private final OutputStream mOutputStream; + private long mBytesWritten; + + /** Constructs a new writer which writes bytes to the given output stream. */ + public RawBackupWriter(OutputStream outputStream) { + this.mOutputStream = outputStream; + } + + @Override + public void writeBytes(byte[] bytes) throws IOException { + mOutputStream.write(bytes); + mBytesWritten += bytes.length; + } + + @Override + public void writeChunk(long start, int length) throws IOException { + throw new UnsupportedOperationException("RawBackupWriter cannot write existing chunks"); + } + + @Override + public long getBytesWritten() { + return mBytesWritten; + } + + @Override + public void flush() throws IOException { + mOutputStream.flush(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriter.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriter.java new file mode 100644 index 000000000000..0e4bd58345d5 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriter.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2018 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.chunking; + +import android.annotation.Nullable; + +import com.android.internal.util.Preconditions; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.Locale; + +/** + * A {@link DiffScriptWriter} that writes an entire diff script to a single {@link OutputStream}. + */ +public class SingleStreamDiffScriptWriter implements DiffScriptWriter { + static final byte LINE_SEPARATOR = 0xA; + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private final int mMaxNewByteChunkSize; + private final OutputStream mOutputStream; + private final byte[] mByteBuffer; + private int mBufferSize = 0; + // Each chunk could be written immediately to the output stream. However, + // it is possible that chunks may overlap. We therefore cache the most recent + // reusable chunk and try to merge it with future chunks. + private ByteRange mReusableChunk; + + public SingleStreamDiffScriptWriter(OutputStream outputStream, int maxNewByteChunkSize) { + mOutputStream = outputStream; + mMaxNewByteChunkSize = maxNewByteChunkSize; + mByteBuffer = new byte[maxNewByteChunkSize]; + } + + @Override + public void writeByte(byte b) throws IOException { + if (mReusableChunk != null) { + writeReusableChunk(); + } + mByteBuffer[mBufferSize++] = b; + if (mBufferSize == mMaxNewByteChunkSize) { + writeByteBuffer(); + } + } + + @Override + public void writeChunk(long chunkStart, int chunkLength) throws IOException { + Preconditions.checkArgument(chunkStart >= 0); + Preconditions.checkArgument(chunkLength > 0); + if (mBufferSize != 0) { + writeByteBuffer(); + } + + if (mReusableChunk != null && mReusableChunk.getEnd() + 1 == chunkStart) { + // The new chunk overlaps the old, so combine them into a single byte range. + mReusableChunk = mReusableChunk.extend(chunkLength); + } else { + writeReusableChunk(); + mReusableChunk = new ByteRange(chunkStart, chunkStart + chunkLength - 1); + } + } + + @Override + public void flush() throws IOException { + Preconditions.checkState(!(mBufferSize != 0 && mReusableChunk != null)); + if (mBufferSize != 0) { + writeByteBuffer(); + } + if (mReusableChunk != null) { + writeReusableChunk(); + } + mOutputStream.flush(); + } + + private void writeByteBuffer() throws IOException { + mOutputStream.write(Integer.toString(mBufferSize).getBytes(UTF_8)); + mOutputStream.write(LINE_SEPARATOR); + mOutputStream.write(mByteBuffer, 0, mBufferSize); + mOutputStream.write(LINE_SEPARATOR); + mBufferSize = 0; + } + + private void writeReusableChunk() throws IOException { + if (mReusableChunk != null) { + mOutputStream.write( + String.format( + Locale.US, + "%d-%d", + mReusableChunk.getStart(), + mReusableChunk.getEnd()) + .getBytes(UTF_8)); + mOutputStream.write(LINE_SEPARATOR); + mReusableChunk = null; + } + } + + /** A factory that creates {@link SingleStreamDiffScriptWriter}s. */ + public static class Factory implements DiffScriptWriter.Factory { + private final int mMaxNewByteChunkSize; + private final OutputStreamWrapper mOutputStreamWrapper; + + public Factory(int maxNewByteChunkSize, @Nullable OutputStreamWrapper outputStreamWrapper) { + mMaxNewByteChunkSize = maxNewByteChunkSize; + mOutputStreamWrapper = outputStreamWrapper; + } + + @Override + public SingleStreamDiffScriptWriter create(OutputStream outputStream) { + if (mOutputStreamWrapper != null) { + outputStream = mOutputStreamWrapper.wrap(outputStream); + } + return new SingleStreamDiffScriptWriter(outputStream, mMaxNewByteChunkSize); + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunker.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunker.java new file mode 100644 index 000000000000..18011f620b24 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunker.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.android.internal.util.Preconditions.checkArgument; + +import com.android.server.backup.encryption.chunking.Chunker; + +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.Arrays; + +/** Splits a stream of bytes into variable-sized chunks, using content-defined chunking. */ +public class ContentDefinedChunker implements Chunker { + private static final int WINDOW_SIZE = 31; + private static final byte DEFAULT_OUT_BYTE = (byte) 0; + + private final byte[] mChunkBuffer; + private final RabinFingerprint64 mRabinFingerprint64; + private final FingerprintMixer mFingerprintMixer; + private final BreakpointPredicate mBreakpointPredicate; + private final int mMinChunkSize; + private final int mMaxChunkSize; + + /** + * Constructor. + * + * @param minChunkSize The minimum size of a chunk. No chunk will be produced of a size smaller + * than this except possibly at the very end of the stream. + * @param maxChunkSize The maximum size of a chunk. No chunk will be produced of a larger size. + * @param rabinFingerprint64 Calculates fingerprints, with which to determine breakpoints. + * @param breakpointPredicate Given a Rabin fingerprint, returns whether this ought to be a + * breakpoint. + */ + public ContentDefinedChunker( + int minChunkSize, + int maxChunkSize, + RabinFingerprint64 rabinFingerprint64, + FingerprintMixer fingerprintMixer, + BreakpointPredicate breakpointPredicate) { + checkArgument( + minChunkSize >= WINDOW_SIZE, + "Minimum chunk size must be greater than window size."); + checkArgument( + maxChunkSize >= minChunkSize, + "Maximum chunk size cannot be smaller than minimum chunk size."); + mChunkBuffer = new byte[maxChunkSize]; + mRabinFingerprint64 = rabinFingerprint64; + mBreakpointPredicate = breakpointPredicate; + mFingerprintMixer = fingerprintMixer; + mMinChunkSize = minChunkSize; + mMaxChunkSize = maxChunkSize; + } + + /** + * Breaks the input stream into variable-sized chunks. + * + * @param inputStream The input bytes to break into chunks. + * @param chunkConsumer A function to process each chunk as it's generated. + * @throws IOException Thrown if there is an issue reading from the input stream. + * @throws GeneralSecurityException Thrown if the {@link ChunkConsumer} throws it. + */ + @Override + public void chunkify(InputStream inputStream, ChunkConsumer chunkConsumer) + throws IOException, GeneralSecurityException { + int chunkLength; + int initialReadLength = mMinChunkSize - WINDOW_SIZE; + + // Performance optimization - there is no reason to calculate fingerprints for windows + // ending before the minimum chunk size. + while ((chunkLength = + inputStream.read(mChunkBuffer, /*off=*/ 0, /*len=*/ initialReadLength)) + != -1) { + int b; + long fingerprint = 0L; + + while ((b = inputStream.read()) != -1) { + byte inByte = (byte) b; + byte outByte = getCurrentWindowStartByte(chunkLength); + mChunkBuffer[chunkLength++] = inByte; + + fingerprint = + mRabinFingerprint64.computeFingerprint64(inByte, outByte, fingerprint); + + if (chunkLength >= mMaxChunkSize + || (chunkLength >= mMinChunkSize + && mBreakpointPredicate.isBreakpoint( + mFingerprintMixer.mix(fingerprint)))) { + chunkConsumer.accept(Arrays.copyOf(mChunkBuffer, chunkLength)); + chunkLength = 0; + break; + } + } + + if (chunkLength > 0) { + chunkConsumer.accept(Arrays.copyOf(mChunkBuffer, chunkLength)); + } + } + } + + private byte getCurrentWindowStartByte(int chunkLength) { + if (chunkLength < mMinChunkSize) { + return DEFAULT_OUT_BYTE; + } else { + return mChunkBuffer[chunkLength - WINDOW_SIZE]; + } + } + + /** Whether the current fingerprint indicates the end of a chunk. */ + public interface BreakpointPredicate { + + /** + * Returns {@code true} if the fingerprint of the last {@code WINDOW_SIZE} bytes indicates + * the chunk ought to end at this position. + * + * @param fingerprint Fingerprint of the last {@code WINDOW_SIZE} bytes. + * @return Whether this ought to be a chunk breakpoint. + */ + boolean isBreakpoint(long fingerprint); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixer.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixer.java new file mode 100644 index 000000000000..e9f30505c112 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixer.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.android.internal.util.Preconditions.checkArgument; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; + +import javax.crypto.SecretKey; + +/** + * Helper for mixing fingerprint with key material. + * + * <p>We do this as otherwise the Rabin fingerprint leaks information about the plaintext. i.e., if + * two users have the same file, it will be partitioned by Rabin in the same way, allowing us to + * infer that it is the same as another user's file. + * + * <p>By mixing the fingerprint with the user's secret key, the chunking method is different on a + * per key basis. Each application has its own {@link SecretKey}, so we cannot infer that a file is + * the same even across multiple applications owned by the same user, never mind across multiple + * users. + * + * <p>Instead of directly mixing the fingerprint with the user's secret, we first securely and + * deterministically derive a secondary chunking key. As Rabin is not a cryptographically secure + * hash, it might otherwise leak information about the user's secret. This prevents that from + * happening. + */ +public class FingerprintMixer { + public static final int SALT_LENGTH_BYTES = 256 / Byte.SIZE; + private static final String DERIVED_KEY_NAME = "RabinFingerprint64Mixer"; + + private final long mAddend; + private final long mMultiplicand; + + /** + * A new instance from a given secret key and salt. Salt must be the same across incremental + * backups, or a different chunking strategy will be used each time, defeating the dedup. + * + * @param secretKey The application-specific secret. + * @param salt The salt. + * @throws InvalidKeyException If the encoded form of {@code secretKey} is inaccessible. + */ + public FingerprintMixer(SecretKey secretKey, byte[] salt) throws InvalidKeyException { + checkArgument(salt.length == SALT_LENGTH_BYTES, "Requires a 256-bit salt."); + byte[] keyBytes = secretKey.getEncoded(); + if (keyBytes == null) { + throw new InvalidKeyException("SecretKey must support encoding for FingerprintMixer."); + } + byte[] derivedKey = + Hkdf.hkdf(keyBytes, salt, DERIVED_KEY_NAME.getBytes(StandardCharsets.UTF_8)); + ByteBuffer buffer = ByteBuffer.wrap(derivedKey); + mAddend = buffer.getLong(); + // Multiplicand must be odd - otherwise we lose some bits of the Rabin fingerprint when + // mixing + mMultiplicand = buffer.getLong() | 1; + } + + /** + * Mixes the fingerprint with the derived key material. This is performed by adding part of the + * derived key and multiplying by another part of the derived key (which is forced to be odd, so + * that the operation is reversible). + * + * @param fingerprint A 64-bit Rabin fingerprint. + * @return The mixed fingerprint. + */ + long mix(long fingerprint) { + return ((fingerprint + mAddend) * mMultiplicand); + } + + /** The addend part of the derived key. */ + long getAddend() { + return mAddend; + } + + /** The multiplicand part of the derived key. */ + long getMultiplicand() { + return mMultiplicand; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/Hkdf.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/Hkdf.java new file mode 100644 index 000000000000..6f4f549ab2d7 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/Hkdf.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.android.internal.util.Preconditions.checkNotNull; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Secure HKDF utils. Allows client to deterministically derive additional key material from a base + * secret. If the derived key material is compromised, this does not in of itself compromise the + * root secret. + * + * <p>TODO(b/116575321): After all code is ported, rename this class to HkdfUtils. + */ +public final class Hkdf { + private static final byte[] CONSTANT_01 = {0x01}; + private static final String HmacSHA256 = "HmacSHA256"; + private static final String AES = "AES"; + + /** + * Implements HKDF (RFC 5869) with the SHA-256 hash and a 256-bit output key length. + * + * <p>IMPORTANT: The use or edit of this method requires a security review. + * + * @param masterKey Master key from which to derive sub-keys. + * @param salt A randomly generated 256-bit byte string. + * @param data Arbitrary information that is bound to the derived key (i.e., used in its + * creation). + * @return Raw derived key bytes = HKDF-SHA256(masterKey, salt, data). + * @throws InvalidKeyException If the salt can not be used as a valid key. + */ + static byte[] hkdf(byte[] masterKey, byte[] salt, byte[] data) throws InvalidKeyException { + checkNotNull(masterKey, "HKDF requires master key to be set."); + checkNotNull(salt, "HKDF requires a salt."); + checkNotNull(data, "No data provided to HKDF."); + return hkdfSha256Expand(hkdfSha256Extract(masterKey, salt), data); + } + + private Hkdf() {} + + /** + * The HKDF (RFC 5869) extraction function, using the SHA-256 hash function. This function is + * used to pre-process the {@code inputKeyMaterial} and mix it with the {@code salt}, producing + * output suitable for use with HKDF expansion function (which produces the actual derived key). + * + * <p>IMPORTANT: The use or edit of this method requires a security review. + * + * @see #hkdfSha256Expand(byte[], byte[]) + * @return HMAC-SHA256(salt, inputKeyMaterial) (salt is the "key" for the HMAC) + * @throws InvalidKeyException If the salt can not be used as a valid key. + */ + private static byte[] hkdfSha256Extract(byte[] inputKeyMaterial, byte[] salt) + throws InvalidKeyException { + // Note that the SecretKey encoding format is defined to be RAW, so the encoded form should + // be consistent across implementations. + Mac sha256; + try { + sha256 = Mac.getInstance(HmacSHA256); + } catch (NoSuchAlgorithmException e) { + // This can not happen - HmacSHA256 is supported by the platform. + throw new AssertionError(e); + } + sha256.init(new SecretKeySpec(salt, AES)); + + return sha256.doFinal(inputKeyMaterial); + } + + /** + * Special case of HKDF (RFC 5869) expansion function, using the SHA-256 hash function and + * allowing for a maximum output length of 256 bits. + * + * <p>IMPORTANT: The use or edit of this method requires a security review. + * + * @param pseudoRandomKey Generated by {@link #hkdfSha256Extract(byte[], byte[])}. + * @param info Arbitrary information the derived key should be bound to. + * @return Raw derived key bytes = HMAC-SHA256(pseudoRandomKey, info | 0x01). + * @throws InvalidKeyException If the salt can not be used as a valid key. + */ + private static byte[] hkdfSha256Expand(byte[] pseudoRandomKey, byte[] info) + throws InvalidKeyException { + // Note that RFC 5869 computes number of blocks N = ceil(hash length / output length), but + // here we only deal with a 256 bit hash up to a 256 bit output, yielding N=1. + Mac sha256; + try { + sha256 = Mac.getInstance(HmacSHA256); + } catch (NoSuchAlgorithmException e) { + // This can not happen - HmacSHA256 is supported by the platform. + throw new AssertionError(e); + } + sha256.init(new SecretKeySpec(pseudoRandomKey, AES)); + + sha256.update(info); + sha256.update(CONSTANT_01); + return sha256.doFinal(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpoint.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpoint.java new file mode 100644 index 000000000000..e867e7c1b801 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpoint.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.android.internal.util.Preconditions.checkArgument; + +import com.android.server.backup.encryption.chunking.cdc.ContentDefinedChunker.BreakpointPredicate; + +/** + * Function to determine whether a 64-bit fingerprint ought to be a chunk breakpoint. + * + * <p>This works by checking whether there are at least n leading zeros in the fingerprint. n is + * calculated to on average cause a breakpoint after a given number of trials (provided in the + * constructor). This allows us to choose a number of trials that gives a desired average chunk + * size. This works because the fingerprint is pseudo-randomly distributed. + */ +public class IsChunkBreakpoint implements BreakpointPredicate { + private final int mLeadingZeros; + private final long mBitmask; + + /** + * A new instance that causes a breakpoint after a given number of trials on average. + * + * @param averageNumberOfTrialsUntilBreakpoint The number of trials after which on average to + * create a new chunk. If this is not a power of 2, some precision is sacrificed (i.e., on + * average, breaks will actually happen after the nearest power of 2 to the average number + * of trials passed in). + */ + public IsChunkBreakpoint(long averageNumberOfTrialsUntilBreakpoint) { + checkArgument( + averageNumberOfTrialsUntilBreakpoint >= 0, + "Average number of trials must be non-negative"); + + // Want n leading zeros after t trials. + // P(leading zeros = n) = 1/2^n + // Expected num trials to get n leading zeros = 1/2^-n + // t = 1/2^-n + // n = log2(t) + mLeadingZeros = (int) Math.round(log2(averageNumberOfTrialsUntilBreakpoint)); + mBitmask = ~(~0L >>> mLeadingZeros); + } + + /** + * Returns {@code true} if {@code fingerprint} indicates that there should be a chunk + * breakpoint. + */ + @Override + public boolean isBreakpoint(long fingerprint) { + return (fingerprint & mBitmask) == 0; + } + + /** Returns the number of leading zeros in the fingerprint that causes a breakpoint. */ + public int getLeadingZeros() { + return mLeadingZeros; + } + + /** + * Calculates log base 2 of x. Not the most efficient possible implementation, but it's simple, + * obviously correct, and is only invoked on object construction. + */ + private static double log2(double x) { + return Math.log(x) / Math.log(2); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64.java new file mode 100644 index 000000000000..1e14ffa5ad77 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +/** Helper to calculate a 64-bit Rabin fingerprint over a 31-byte window. */ +public class RabinFingerprint64 { + private static final long DEFAULT_IRREDUCIBLE_POLYNOMIAL_64 = 0x000000000000001BL; + private static final int POLYNOMIAL_DEGREE = 64; + private static final int SLIDING_WINDOW_SIZE_BYTES = 31; + + private final long mPoly64; + // Auxiliary tables to speed up the computation of Rabin fingerprints. + private final long[] mTableFP64 = new long[256]; + private final long[] mTableOutByte = new long[256]; + + /** + * Constructs a new instance over the given irreducible 64-degree polynomial. It is up to the + * caller to determine that the polynomial is irreducible. If it is not the fingerprinting will + * not behave as expected. + * + * @param poly64 The polynomial. + */ + public RabinFingerprint64(long poly64) { + mPoly64 = poly64; + } + + /** Constructs a new instance using {@code x^64 + x^4 + x + 1} as the irreducible polynomial. */ + public RabinFingerprint64() { + this(DEFAULT_IRREDUCIBLE_POLYNOMIAL_64); + computeFingerprintTables64(); + computeFingerprintTables64Windowed(); + } + + /** + * Computes the fingerprint for the new sliding window given the fingerprint of the previous + * sliding window, the byte sliding in, and the byte sliding out. + * + * @param inChar The new char coming into the sliding window. + * @param outChar The left most char sliding out of the window. + * @param fingerPrint Fingerprint for previous window. + * @return New fingerprint for the new sliding window. + */ + public long computeFingerprint64(byte inChar, byte outChar, long fingerPrint) { + return (fingerPrint << 8) + ^ (inChar & 0xFF) + ^ mTableFP64[(int) (fingerPrint >>> 56)] + ^ mTableOutByte[outChar & 0xFF]; + } + + /** Compute auxiliary tables to speed up the fingerprint computation. */ + private void computeFingerprintTables64() { + long[] degreesRes64 = new long[POLYNOMIAL_DEGREE]; + degreesRes64[0] = mPoly64; + for (int i = 1; i < POLYNOMIAL_DEGREE; i++) { + if ((degreesRes64[i - 1] & (1L << 63)) == 0) { + degreesRes64[i] = degreesRes64[i - 1] << 1; + } else { + degreesRes64[i] = (degreesRes64[i - 1] << 1) ^ mPoly64; + } + } + for (int i = 0; i < 256; i++) { + int currIndex = i; + for (int j = 0; (currIndex > 0) && (j < 8); j++) { + if ((currIndex & 0x1) == 1) { + mTableFP64[i] ^= degreesRes64[j]; + } + currIndex >>>= 1; + } + } + } + + /** + * Compute auxiliary table {@code mTableOutByte} to facilitate the computing of fingerprints for + * sliding windows. This table is to take care of the effect on the fingerprint when the + * leftmost byte in the window slides out. + */ + private void computeFingerprintTables64Windowed() { + // Auxiliary array degsRes64[8] defined by: <code>degsRes64[i] = x^(8 * + // SLIDING_WINDOW_SIZE_BYTES + i) mod this.mPoly64.</code> + long[] degsRes64 = new long[8]; + degsRes64[0] = mPoly64; + for (int i = 65; i < 8 * (SLIDING_WINDOW_SIZE_BYTES + 1); i++) { + if ((degsRes64[(i - 1) % 8] & (1L << 63)) == 0) { + degsRes64[i % 8] = degsRes64[(i - 1) % 8] << 1; + } else { + degsRes64[i % 8] = (degsRes64[(i - 1) % 8] << 1) ^ mPoly64; + } + } + for (int i = 0; i < 256; i++) { + int currIndex = i; + for (int j = 0; (currIndex > 0) && (j < 8); j++) { + if ((currIndex & 0x1) == 1) { + mTableOutByte[i] ^= degsRes64[j]; + } + currIndex >>>= 1; + } + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/KeyWrapUtils.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/KeyWrapUtils.java new file mode 100644 index 000000000000..a043c1fe687f --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/KeyWrapUtils.java @@ -0,0 +1,132 @@ +/* + * 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.keys; + +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; + +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +/** Utility functions for wrapping and unwrapping tertiary keys. */ +public class KeyWrapUtils { + private static final String AES_GCM_MODE = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH_BYTES = 16; + private static final int BITS_PER_BYTE = 8; + private static final int GCM_TAG_LENGTH_BITS = GCM_TAG_LENGTH_BYTES * BITS_PER_BYTE; + private static final String KEY_ALGORITHM = "AES"; + + /** + * Uses the secondary key to unwrap the wrapped tertiary key. + * + * @param secondaryKey The secondary key used to wrap the tertiary key. + * @param wrappedKey The wrapped tertiary key. + * @return The unwrapped tertiary key. + * @throws InvalidKeyException if the provided secondary key cannot unwrap the tertiary key. + */ + public static SecretKey unwrap(SecretKey secondaryKey, WrappedKeyProto.WrappedKey wrappedKey) + throws InvalidKeyException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, NoSuchPaddingException { + if (wrappedKey.wrapAlgorithm != WrappedKeyProto.WrappedKey.AES_256_GCM) { + throw new InvalidKeyException( + String.format( + Locale.US, + "Could not unwrap key wrapped with %s algorithm", + wrappedKey.wrapAlgorithm)); + } + + if (wrappedKey.metadata == null) { + throw new InvalidKeyException("Metadata missing from wrapped tertiary key."); + } + + if (wrappedKey.metadata.type != WrappedKeyProto.KeyMetadata.AES_256_GCM) { + throw new InvalidKeyException( + String.format( + Locale.US, + "Wrapped key was unexpected %s algorithm. Only support" + + " AES/GCM/NoPadding.", + wrappedKey.metadata.type)); + } + + Cipher cipher = getCipher(); + + cipher.init( + Cipher.UNWRAP_MODE, + secondaryKey, + new GCMParameterSpec(GCM_TAG_LENGTH_BITS, wrappedKey.nonce)); + + return (SecretKey) cipher.unwrap(wrappedKey.key, KEY_ALGORITHM, Cipher.SECRET_KEY); + } + + /** + * Wraps the tertiary key with the secondary key. + * + * @param secondaryKey The secondary key to use for wrapping. + * @param tertiaryKey The key to wrap. + * @return The wrapped key. + * @throws InvalidKeyException if the key is not good for wrapping. + * @throws IllegalBlockSizeException if there is an issue wrapping. + */ + public static WrappedKeyProto.WrappedKey wrap(SecretKey secondaryKey, SecretKey tertiaryKey) + throws InvalidKeyException, IllegalBlockSizeException, NoSuchAlgorithmException, + NoSuchPaddingException { + Cipher cipher = getCipher(); + cipher.init(Cipher.WRAP_MODE, secondaryKey); + + WrappedKeyProto.WrappedKey wrappedKey = new WrappedKeyProto.WrappedKey(); + wrappedKey.key = cipher.wrap(tertiaryKey); + wrappedKey.nonce = cipher.getIV(); + wrappedKey.wrapAlgorithm = WrappedKeyProto.WrappedKey.AES_256_GCM; + wrappedKey.metadata = new WrappedKeyProto.KeyMetadata(); + wrappedKey.metadata.type = WrappedKeyProto.KeyMetadata.AES_256_GCM; + return wrappedKey; + } + + /** + * Rewraps a tertiary key with a new secondary key. + * + * @param oldSecondaryKey The old secondary key, used to unwrap the tertiary key. + * @param newSecondaryKey The new secondary key, used to rewrap the tertiary key. + * @param tertiaryKey The tertiary key, wrapped by {@code oldSecondaryKey}. + * @return The tertiary key, wrapped by {@code newSecondaryKey}. + * @throws InvalidKeyException if the key is not good for wrapping or unwrapping. + * @throws IllegalBlockSizeException if there is an issue wrapping. + */ + public static WrappedKeyProto.WrappedKey rewrap( + SecretKey oldSecondaryKey, + SecretKey newSecondaryKey, + WrappedKeyProto.WrappedKey tertiaryKey) + throws InvalidKeyException, IllegalBlockSizeException, + InvalidAlgorithmParameterException, NoSuchAlgorithmException, + NoSuchPaddingException { + return wrap(newSecondaryKey, unwrap(oldSecondaryKey, tertiaryKey)); + } + + private static Cipher getCipher() throws NoSuchPaddingException, NoSuchAlgorithmException { + return Cipher.getInstance(AES_GCM_MODE); + } + + // Statics only + private KeyWrapUtils() {} +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKey.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKey.java new file mode 100644 index 000000000000..f356b4f102e2 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKey.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2018 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.keys; + +import static com.android.internal.util.Preconditions.checkNotNull; + +import android.annotation.IntDef; +import android.content.Context; +import android.security.keystore.recovery.InternalRecoveryServiceException; +import android.security.keystore.recovery.RecoveryController; +import android.util.Slog; + +import javax.crypto.SecretKey; + +/** + * Wraps a {@link RecoveryController}'s {@link SecretKey}. These are kept in "AndroidKeyStore" (a + * provider for {@link java.security.KeyStore} and {@link javax.crypto.KeyGenerator}. They are also + * synced with the recoverable key store, wrapped by the primary key. This allows them to be + * recovered on a user's subsequent device through providing their lock screen secret. + */ +public class RecoverableKeyStoreSecondaryKey { + private static final String TAG = "RecoverableKeyStoreSecondaryKey"; + + private final String mAlias; + private final SecretKey mSecretKey; + + /** + * A new instance. + * + * @param alias The alias. It is keyed with this in AndroidKeyStore and the recoverable key + * store. + * @param secretKey The key. + */ + public RecoverableKeyStoreSecondaryKey(String alias, SecretKey secretKey) { + mAlias = checkNotNull(alias); + mSecretKey = checkNotNull(secretKey); + } + + /** + * The ID, as stored in the recoverable {@link java.security.KeyStore}, and as used to identify + * wrapped tertiary keys on the backup server. + */ + public String getAlias() { + return mAlias; + } + + /** The secret key, to be used to wrap tertiary keys. */ + public SecretKey getSecretKey() { + return mSecretKey; + } + + /** + * The status of the key. i.e., whether it's been synced to remote trusted hardware. + * + * @param context The application context. + * @return One of {@link Status#SYNCED}, {@link Status#NOT_SYNCED} or {@link Status#DESTROYED}. + */ + public @Status int getStatus(Context context) { + try { + return getStatusInternal(context); + } catch (InternalRecoveryServiceException e) { + Slog.wtf(TAG, "Internal error getting recovery status", e); + // Return NOT_SYNCED by default, as we do not want the backups to fail or to repeatedly + // attempt to reinitialize. + return Status.NOT_SYNCED; + } + } + + private @Status int getStatusInternal(Context context) throws InternalRecoveryServiceException { + int status = RecoveryController.getInstance(context).getRecoveryStatus(mAlias); + switch (status) { + case RecoveryController.RECOVERY_STATUS_PERMANENT_FAILURE: + return Status.DESTROYED; + case RecoveryController.RECOVERY_STATUS_SYNCED: + return Status.SYNCED; + case RecoveryController.RECOVERY_STATUS_SYNC_IN_PROGRESS: + return Status.NOT_SYNCED; + default: + // Throw an exception if we encounter a status that doesn't match any of the above. + throw new InternalRecoveryServiceException( + "Unexpected status from getRecoveryStatus: " + status); + } + } + + /** Status of a key in the recoverable key store. */ + @IntDef({Status.NOT_SYNCED, Status.SYNCED, Status.DESTROYED}) + public @interface Status { + /** + * The key has not yet been synced to remote trusted hardware. This may be because the user + * has not yet unlocked their device. + */ + int NOT_SYNCED = 1; + + /** + * The key has been synced with remote trusted hardware. It should now be recoverable on + * another device. + */ + int SYNCED = 2; + + /** The key has been lost forever. This can occur if the user disables their lock screen. */ + int DESTROYED = 3; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManager.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManager.java new file mode 100644 index 000000000000..c89076b9928f --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManager.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2018 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.keys; + +import android.content.Context; +import android.security.keystore.recovery.InternalRecoveryServiceException; +import android.security.keystore.recovery.LockScreenRequiredException; +import android.security.keystore.recovery.RecoveryController; + +import com.android.internal.annotations.VisibleForTesting; + +import libcore.util.HexEncoding; + +import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; +import java.util.Optional; + +import javax.crypto.SecretKey; + +/** + * Manages generating, deleting, and retrieving secondary keys through {@link RecoveryController}. + * + * <p>The recoverable key store will be synced remotely via the {@link RecoveryController}, allowing + * recovery of keys on other devices owned by the user. + */ +public class RecoverableKeyStoreSecondaryKeyManager { + private static final String BACKUP_KEY_ALIAS_PREFIX = + "com.android.server.backup/recoverablekeystore/"; + private static final int BACKUP_KEY_SUFFIX_LENGTH_BITS = 128; + private static final int BITS_PER_BYTE = 8; + + /** A new instance. */ + public static RecoverableKeyStoreSecondaryKeyManager getInstance(Context context) { + return new RecoverableKeyStoreSecondaryKeyManager( + RecoveryController.getInstance(context), new SecureRandom()); + } + + private final RecoveryController mRecoveryController; + private final SecureRandom mSecureRandom; + + @VisibleForTesting + public RecoverableKeyStoreSecondaryKeyManager( + RecoveryController recoveryController, SecureRandom secureRandom) { + mRecoveryController = recoveryController; + mSecureRandom = secureRandom; + } + + /** + * Generates a new recoverable key using the {@link RecoveryController}. + * + * @throws InternalRecoveryServiceException if an unexpected error occurred generating the key. + * @throws LockScreenRequiredException if the user does not have a lock screen. A lock screen is + * required to generate a recoverable key. + */ + public RecoverableKeyStoreSecondaryKey generate() + throws InternalRecoveryServiceException, LockScreenRequiredException, + UnrecoverableKeyException { + String alias = generateId(); + mRecoveryController.generateKey(alias); + SecretKey key = (SecretKey) mRecoveryController.getKey(alias); + if (key == null) { + throw new InternalRecoveryServiceException( + String.format( + "Generated key %s but could not get it back immediately afterwards.", + alias)); + } + return new RecoverableKeyStoreSecondaryKey(alias, key); + } + + /** + * Removes the secondary key. This means the key will no longer be recoverable. + * + * @param alias The alias of the key. + * @throws InternalRecoveryServiceException if there was a {@link RecoveryController} error. + */ + public void remove(String alias) throws InternalRecoveryServiceException { + mRecoveryController.removeKey(alias); + } + + /** + * Returns the {@link RecoverableKeyStoreSecondaryKey} with {@code alias} if it is in the {@link + * RecoveryController}. Otherwise, {@link Optional#empty()}. + */ + public Optional<RecoverableKeyStoreSecondaryKey> get(String alias) + throws InternalRecoveryServiceException, UnrecoverableKeyException { + SecretKey secretKey = (SecretKey) mRecoveryController.getKey(alias); + return Optional.ofNullable(secretKey) + .map(key -> new RecoverableKeyStoreSecondaryKey(alias, key)); + } + + /** + * Generates a new key alias. This has more entropy than a UUID - it can be considered + * universally unique. + */ + private String generateId() { + byte[] id = new byte[BACKUP_KEY_SUFFIX_LENGTH_BITS / BITS_PER_BYTE]; + mSecureRandom.nextBytes(id); + return BACKUP_KEY_ALIAS_PREFIX + HexEncoding.encodeToString(id); + } + + /** Constructs a {@link RecoverableKeyStoreSecondaryKeyManager}. */ + public interface RecoverableKeyStoreSecondaryKeyManagerProvider { + /** Returns a newly constructed {@link RecoverableKeyStoreSecondaryKeyManager}. */ + RecoverableKeyStoreSecondaryKeyManager get(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RestoreKeyFetcher.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RestoreKeyFetcher.java new file mode 100644 index 000000000000..6fb958bd1c1e --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/RestoreKeyFetcher.java @@ -0,0 +1,71 @@ +/* + * 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.keys; + +import android.security.keystore.recovery.InternalRecoveryServiceException; + +import com.android.server.backup.encryption.keys.RecoverableKeyStoreSecondaryKeyManager.RecoverableKeyStoreSecondaryKeyManagerProvider; +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; + +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.util.Optional; + +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; + +/** Fetches the secondary key and uses it to unwrap the tertiary key during restore. */ +public class RestoreKeyFetcher { + + /** + * Retrieves the secondary key with the given alias and uses it to unwrap the given wrapped + * tertiary key. + * + * @param secondaryKeyManagerProvider Provider which creates {@link + * RecoverableKeyStoreSecondaryKeyManager} + * @param secondaryKeyAlias Alias of the secondary key used to wrap the tertiary key + * @param wrappedTertiaryKey Tertiary key wrapped with the secondary key above + * @return The unwrapped tertiary key + */ + public static SecretKey unwrapTertiaryKey( + RecoverableKeyStoreSecondaryKeyManagerProvider secondaryKeyManagerProvider, + String secondaryKeyAlias, + WrappedKeyProto.WrappedKey wrappedTertiaryKey) + throws KeyException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, + NoSuchPaddingException { + Optional<RecoverableKeyStoreSecondaryKey> secondaryKey = + getSecondaryKey(secondaryKeyManagerProvider, secondaryKeyAlias); + if (!secondaryKey.isPresent()) { + throw new KeyException("No key:" + secondaryKeyAlias); + } + + return KeyWrapUtils.unwrap(secondaryKey.get().getSecretKey(), wrappedTertiaryKey); + } + + private static Optional<RecoverableKeyStoreSecondaryKey> getSecondaryKey( + RecoverableKeyStoreSecondaryKeyManagerProvider secondaryKeyManagerProvider, + String secondaryKeyAlias) + throws KeyException { + try { + return secondaryKeyManagerProvider.get().get(secondaryKeyAlias); + } catch (InternalRecoveryServiceException | UnrecoverableKeyException e) { + throw new KeyException("Could not retrieve key:" + secondaryKeyAlias, e); + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyGenerator.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyGenerator.java new file mode 100644 index 000000000000..a425c720b9b8 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyGenerator.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2018 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.keys; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +/** 256-bit AES key generator. Each app should have its own separate AES key. */ +public class TertiaryKeyGenerator { + private static final int KEY_SIZE_BITS = 256; + private static final String KEY_ALGORITHM = "AES"; + + private final KeyGenerator mKeyGenerator; + + /** New instance generating keys using {@code secureRandom}. */ + public TertiaryKeyGenerator(SecureRandom secureRandom) { + try { + mKeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM); + mKeyGenerator.init(KEY_SIZE_BITS, secureRandom); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError( + "Impossible condition: JCE thinks it does not support AES.", e); + } + } + + /** Generates a new random AES key. */ + public SecretKey generate() { + return mKeyGenerator.generateKey(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationScheduler.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationScheduler.java new file mode 100644 index 000000000000..f16a68d64213 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationScheduler.java @@ -0,0 +1,104 @@ +/* + * 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.keys; + +import android.content.Context; + +import com.android.internal.annotations.VisibleForTesting; + +/** + * Schedules tertiary key rotations in a staggered fashion. + * + * <p>Apps are due a key rotation after a certain number of backups. Rotations are then staggerered + * over a period of time, through restricting the number of rotations allowed in a 24-hour window. + * This will causes the apps to enter a staggered cycle of regular rotations. + * + * <p>Note: the methods in this class are not optimized to be super fast. They make blocking IO to + * ensure that scheduler information is committed to disk, so that it is available after the user + * turns their device off and on. This ought to be fine as + * + * <ul> + * <li>It will be invoked before a backup, so should never be invoked on the UI thread + * <li>It will be invoked before a backup, so the vast amount of time is spent on the backup, not + * writing tiny amounts of data to disk. + * </ul> + */ +public class TertiaryKeyRotationScheduler { + /** Default number of key rotations allowed within 24 hours. */ + private static final int KEY_ROTATION_LIMIT = 2; + + /** A new instance, using {@code context} to determine where to store state. */ + public static TertiaryKeyRotationScheduler getInstance(Context context) { + TertiaryKeyRotationWindowedCount windowedCount = + TertiaryKeyRotationWindowedCount.getInstance(context); + TertiaryKeyRotationTracker tracker = TertiaryKeyRotationTracker.getInstance(context); + return new TertiaryKeyRotationScheduler(tracker, windowedCount, KEY_ROTATION_LIMIT); + } + + private final TertiaryKeyRotationTracker mTracker; + private final TertiaryKeyRotationWindowedCount mWindowedCount; + private final int mMaximumRotationsPerWindow; + + /** + * A new instance. + * + * @param tracker Tracks how many times each application has backed up. + * @param windowedCount Tracks how many rotations have happened in the last 24 hours. + * @param maximumRotationsPerWindow The maximum number of key rotations allowed per 24 hours. + */ + @VisibleForTesting + TertiaryKeyRotationScheduler( + TertiaryKeyRotationTracker tracker, + TertiaryKeyRotationWindowedCount windowedCount, + int maximumRotationsPerWindow) { + mTracker = tracker; + mWindowedCount = windowedCount; + mMaximumRotationsPerWindow = maximumRotationsPerWindow; + } + + /** + * Returns {@code true} if the app with {@code packageName} is due having its key rotated. + * + * <p>This ought to be queried before backing up an app, to determine whether to do an + * incremental backup or a full backup. (A full backup forces key rotation.) + */ + public boolean isKeyRotationDue(String packageName) { + if (mWindowedCount.getCount() >= mMaximumRotationsPerWindow) { + return false; + } + return mTracker.isKeyRotationDue(packageName); + } + + /** + * Records that a backup happened for the app with the given {@code packageName}. + * + * <p>Each backup brings the app closer to the point at which a key rotation is due. + */ + public void recordBackup(String packageName) { + mTracker.recordBackup(packageName); + } + + /** + * Records a key rotation happened for the app with the given {@code packageName}. + * + * <p>This resets the countdown until the next key rotation is due. + */ + public void recordKeyRotation(String packageName) { + mTracker.resetCountdown(packageName); + mWindowedCount.record(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTracker.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTracker.java new file mode 100644 index 000000000000..1a281e79cc48 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTracker.java @@ -0,0 +1,127 @@ +/* + * 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.keys; + +import static com.android.internal.util.Preconditions.checkArgument; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Slog; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.Locale; + +/** + * Tracks when a tertiary key rotation is due. + * + * <p>After a certain number of incremental backups, the device schedules a full backup, which will + * generate a new encryption key, effecting a key rotation. We should do this on a regular basis so + * that if a key does become compromised it has limited value to the attacker. + * + * <p>No additional synchronization of this class is provided. Only one instance should be used at + * any time. This should be fine as there should be no parallelism in backups. + */ +public class TertiaryKeyRotationTracker { + private static final int MAX_BACKUPS_UNTIL_TERTIARY_KEY_ROTATION = 31; + private static final String SHARED_PREFERENCES_NAME = "tertiary_key_rotation_tracker"; + + private static final String TAG = "TertiaryKeyRotationTracker"; + private static final boolean DEBUG = false; + + /** + * A new instance, using {@code context} to commit data to disk via {@link SharedPreferences}. + */ + public static TertiaryKeyRotationTracker getInstance(Context context) { + return new TertiaryKeyRotationTracker( + context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE), + MAX_BACKUPS_UNTIL_TERTIARY_KEY_ROTATION); + } + + private final SharedPreferences mSharedPreferences; + private final int mMaxBackupsTillRotation; + + /** + * New instance, storing data in {@code sharedPreferences} and initializing backup countdown to + * {@code maxBackupsTillRotation}. + */ + @VisibleForTesting + TertiaryKeyRotationTracker(SharedPreferences sharedPreferences, int maxBackupsTillRotation) { + checkArgument( + maxBackupsTillRotation >= 0, + String.format( + Locale.US, + "maxBackupsTillRotation should be non-negative but was %d", + maxBackupsTillRotation)); + mSharedPreferences = sharedPreferences; + mMaxBackupsTillRotation = maxBackupsTillRotation; + } + + /** + * Returns {@code true} if the given app is due having its key rotated. + * + * @param packageName The package name of the app. + */ + public boolean isKeyRotationDue(String packageName) { + return getBackupsSinceRotation(packageName) >= mMaxBackupsTillRotation; + } + + /** + * Records that an incremental backup has occurred. Each incremental backup brings the app + * closer to the time when its key should be rotated. + * + * @param packageName The package name of the app for which the backup occurred. + */ + public void recordBackup(String packageName) { + int backupsSinceRotation = getBackupsSinceRotation(packageName) + 1; + mSharedPreferences.edit().putInt(packageName, backupsSinceRotation).apply(); + if (DEBUG) { + Slog.d( + TAG, + String.format( + Locale.US, + "Incremental backup for %s. %d backups until key rotation.", + packageName, + Math.max( + 0, + mMaxBackupsTillRotation + - backupsSinceRotation))); + } + } + + /** + * Resets the rotation delay for the given app. Should be invoked after a key rotation. + * + * @param packageName Package name of the app whose key has rotated. + */ + public void resetCountdown(String packageName) { + mSharedPreferences.edit().putInt(packageName, 0).apply(); + } + + /** Marks all enrolled packages for key rotation. */ + public void markAllForRotation() { + SharedPreferences.Editor editor = mSharedPreferences.edit(); + for (String packageName : mSharedPreferences.getAll().keySet()) { + editor.putInt(packageName, mMaxBackupsTillRotation); + } + editor.apply(); + } + + private int getBackupsSinceRotation(String packageName) { + return mSharedPreferences.getInt(packageName, 0); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationWindowedCount.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationWindowedCount.java new file mode 100644 index 000000000000..b90343ad4b35 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationWindowedCount.java @@ -0,0 +1,132 @@ +/* + * 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.keys; + +import android.content.Context; +import android.util.Slog; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +/** + * Tracks (and commits to disk) how many key rotations have happened in the last 24 hours. This + * allows us to limit (and therefore stagger) the number of key rotations in a given period of time. + * + * <p>Note to engineers thinking of replacing the below with fancier algorithms and data structures: + * we expect the total size of this count at any time to be below however many rotations we allow in + * the window, which is going to be in single digits. Any changes that mean we write to disk more + * frequently, that the code is no longer resistant to clock changes, or that the code is more + * difficult to understand are almost certainly not worthwhile. + */ +public class TertiaryKeyRotationWindowedCount { + private static final String TAG = "TertiaryKeyRotCount"; + + private static final int WINDOW_IN_HOURS = 24; + private static final String LOG_FILE_NAME = "tertiary_key_rotation_windowed_count"; + + private final Clock mClock; + private final File mFile; + private ArrayList<Long> mEvents; + + /** Returns a new instance, persisting state to the files dir of {@code context}. */ + public static TertiaryKeyRotationWindowedCount getInstance(Context context) { + File logFile = new File(context.getFilesDir(), LOG_FILE_NAME); + return new TertiaryKeyRotationWindowedCount(logFile, Clock.systemDefaultZone()); + } + + /** A new instance, committing state to {@code file}, and reading time from {@code clock}. */ + @VisibleForTesting + TertiaryKeyRotationWindowedCount(File file, Clock clock) { + mFile = file; + mClock = clock; + mEvents = new ArrayList<>(); + try { + loadFromFile(); + } catch (IOException e) { + Slog.e(TAG, "Error reading " + LOG_FILE_NAME, e); + } + } + + /** Records a key rotation at the current time. */ + public void record() { + mEvents.add(mClock.millis()); + compact(); + try { + saveToFile(); + } catch (IOException e) { + Slog.e(TAG, "Error saving " + LOG_FILE_NAME, e); + } + } + + /** Returns the number of key rotation that have been recorded in the window. */ + public int getCount() { + compact(); + return mEvents.size(); + } + + private void compact() { + long minimumTimestamp = getMinimumTimestamp(); + long now = mClock.millis(); + ArrayList<Long> compacted = new ArrayList<>(); + for (long event : mEvents) { + if (event >= minimumTimestamp && event <= now) { + compacted.add(event); + } + } + mEvents = compacted; + } + + private long getMinimumTimestamp() { + return mClock.millis() - TimeUnit.HOURS.toMillis(WINDOW_IN_HOURS) + 1; + } + + private void loadFromFile() throws IOException { + if (!mFile.exists()) { + return; + } + try (FileInputStream fis = new FileInputStream(mFile); + DataInputStream dis = new DataInputStream(fis)) { + while (true) { + mEvents.add(dis.readLong()); + } + } catch (EOFException eof) { + // expected + } + } + + private void saveToFile() throws IOException { + // File size is maximum number of key rotations in window multiplied by 8 bytes, which is + // why + // we just overwrite it each time. We expect it will always be less than 100 bytes in size. + try (FileOutputStream fos = new FileOutputStream(mFile); + DataOutputStream dos = new DataOutputStream(fos)) { + for (long event : mEvents) { + dos.writeLong(event); + } + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyStore.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyStore.java new file mode 100644 index 000000000000..01444bf0cd00 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/keys/TertiaryKeyStore.java @@ -0,0 +1,202 @@ +/* + * 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.keys; + +import static com.android.internal.util.Preconditions.checkArgument; + +import android.content.Context; +import android.util.ArrayMap; + +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; +import com.android.server.backup.encryption.storage.BackupEncryptionDb; +import com.android.server.backup.encryption.storage.TertiaryKey; +import com.android.server.backup.encryption.storage.TertiaryKeysTable; + +import com.google.protobuf.nano.CodedOutputByteBufferNano; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.Optional; + +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; + +/** + * Stores backup package keys. Each application package has its own {@link SecretKey}, which is used + * to encrypt the backup data. These keys are then wrapped by a master backup key, and stored in + * their wrapped form on disk and on the backup server. + * + * <p>For now this code only implements writing to disk. Once the backup server is ready, it will be + * extended to sync the keys there, also. + */ +public class TertiaryKeyStore { + + private final RecoverableKeyStoreSecondaryKey mSecondaryKey; + private final BackupEncryptionDb mDatabase; + + /** + * Creates an instance, using {@code secondaryKey} to wrap tertiary keys, and storing them in + * the database. + */ + public static TertiaryKeyStore newInstance( + Context context, RecoverableKeyStoreSecondaryKey secondaryKey) { + return new TertiaryKeyStore(secondaryKey, BackupEncryptionDb.newInstance(context)); + } + + private TertiaryKeyStore( + RecoverableKeyStoreSecondaryKey secondaryKey, BackupEncryptionDb database) { + mSecondaryKey = secondaryKey; + mDatabase = database; + } + + /** + * Saves the given key. + * + * @param applicationName The package name of the application for which this key will be used to + * encrypt data. e.g., "com.example.app". + * @param key The key. + * @throws InvalidKeyException if the backup key is not capable of wrapping. + * @throws IOException if there is an issue writing to the database. + */ + public void save(String applicationName, SecretKey key) + throws IOException, InvalidKeyException, IllegalBlockSizeException, + NoSuchPaddingException, NoSuchAlgorithmException { + checkApplicationName(applicationName); + + byte[] keyBytes = getEncodedKey(KeyWrapUtils.wrap(mSecondaryKey.getSecretKey(), key)); + + long pk; + try { + pk = + mDatabase + .getTertiaryKeysTable() + .addKey( + new TertiaryKey( + mSecondaryKey.getAlias(), applicationName, keyBytes)); + } finally { + mDatabase.close(); + } + + if (pk == -1) { + throw new IOException("Failed to commit to db"); + } + } + + /** + * Tries to load a key for the given application. + * + * @param applicationName The package name of the application, e.g. "com.example.app". + * @return The key if it is exists, {@link Optional#empty()} ()} otherwise. + * @throws InvalidKeyException if the backup key is not good for unwrapping. + * @throws IOException if there is a problem loading the key from the database. + */ + public Optional<SecretKey> load(String applicationName) + throws IOException, InvalidKeyException, InvalidAlgorithmParameterException, + NoSuchAlgorithmException, NoSuchPaddingException { + checkApplicationName(applicationName); + + Optional<TertiaryKey> keyFromDb; + try { + keyFromDb = + mDatabase + .getTertiaryKeysTable() + .getKey(mSecondaryKey.getAlias(), applicationName); + } finally { + mDatabase.close(); + } + + if (!keyFromDb.isPresent()) { + return Optional.empty(); + } + + WrappedKeyProto.WrappedKey wrappedKey = + WrappedKeyProto.WrappedKey.parseFrom(keyFromDb.get().getWrappedKeyBytes()); + return Optional.of(KeyWrapUtils.unwrap(mSecondaryKey.getSecretKey(), wrappedKey)); + } + + /** + * Loads keys for all applications. + * + * @return All of the keys in a map keyed by package name. + * @throws IOException if there is an issue loading from the database. + * @throws InvalidKeyException if the backup key is not an appropriate key for unwrapping. + */ + public Map<String, SecretKey> getAll() + throws IOException, InvalidKeyException, InvalidAlgorithmParameterException, + NoSuchAlgorithmException, NoSuchPaddingException { + Map<String, TertiaryKey> tertiaries; + try { + tertiaries = mDatabase.getTertiaryKeysTable().getAllKeys(mSecondaryKey.getAlias()); + } finally { + mDatabase.close(); + } + + Map<String, SecretKey> unwrappedKeys = new ArrayMap<>(); + for (String applicationName : tertiaries.keySet()) { + WrappedKeyProto.WrappedKey wrappedKey = + WrappedKeyProto.WrappedKey.parseFrom( + tertiaries.get(applicationName).getWrappedKeyBytes()); + unwrappedKeys.put( + applicationName, KeyWrapUtils.unwrap(mSecondaryKey.getSecretKey(), wrappedKey)); + } + + return unwrappedKeys; + } + + /** + * Adds all wrapped keys to the database. + * + * @throws IOException if an error occurred adding a wrapped key. + */ + public void putAll(Map<String, WrappedKeyProto.WrappedKey> wrappedKeysByApplicationName) + throws IOException { + TertiaryKeysTable tertiaryKeysTable = mDatabase.getTertiaryKeysTable(); + try { + + for (String applicationName : wrappedKeysByApplicationName.keySet()) { + byte[] keyBytes = getEncodedKey(wrappedKeysByApplicationName.get(applicationName)); + long primaryKey = + tertiaryKeysTable.addKey( + new TertiaryKey( + mSecondaryKey.getAlias(), applicationName, keyBytes)); + + if (primaryKey == -1) { + throw new IOException("Failed to commit to db"); + } + } + + } finally { + mDatabase.close(); + } + } + + private static void checkApplicationName(String applicationName) { + checkArgument(!applicationName.isEmpty(), "applicationName must not be empty string."); + checkArgument(!applicationName.contains("/"), "applicationName must not contain slash."); + } + + private byte[] getEncodedKey(WrappedKeyProto.WrappedKey key) throws IOException { + byte[] buffer = new byte[key.getSerializedSize()]; + CodedOutputByteBufferNano out = CodedOutputByteBufferNano.newInstance(buffer); + key.writeTo(out); + return buffer; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDb.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDb.java new file mode 100644 index 000000000000..9f6c03a6f393 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDb.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2018 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.storage; + +import android.content.Context; + +/** + * Backup encryption SQLite database. All instances are threadsafe. + * + * <p>The database is automatically opened when accessing one of the tables. After the caller is + * done they must call {@link #close()}. + */ +public class BackupEncryptionDb { + private final BackupEncryptionDbHelper mHelper; + + /** A new instance, using the storage defined by {@code context}. */ + public static BackupEncryptionDb newInstance(Context context) { + BackupEncryptionDbHelper helper = new BackupEncryptionDbHelper(context); + helper.setWriteAheadLoggingEnabled(true); + return new BackupEncryptionDb(helper); + } + + private BackupEncryptionDb(BackupEncryptionDbHelper helper) { + mHelper = helper; + } + + public TertiaryKeysTable getTertiaryKeysTable() { + return new TertiaryKeysTable(mHelper); + } + + /** Deletes the database. */ + public void clear() throws EncryptionDbException { + mHelper.resetDatabase(); + } + + /** + * Closes the database if it is open. + * + * <p>After calling this, the caller may access one of the tables again which will automatically + * reopen the database. + */ + public void close() { + mHelper.close(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDbContract.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDbContract.java new file mode 100644 index 000000000000..5e8a8d9fc2ae --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDbContract.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2018 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.storage; + +import android.provider.BaseColumns; + +/** Contract for the backup encryption database. Describes tables present. */ +class BackupEncryptionDbContract { + /** + * Table containing tertiary keys belonging to the user. Tertiary keys are wrapped by a + * secondary key, which never leaves {@code AndroidKeyStore} (a provider for {@link + * java.security.KeyStore}). Each application has a tertiary key, which is used to encrypt the + * backup data. + */ + static class TertiaryKeysEntry implements BaseColumns { + static final String TABLE_NAME = "tertiary_keys"; + + /** Alias of the secondary key used to wrap the tertiary key. */ + static final String COLUMN_NAME_SECONDARY_KEY_ALIAS = "secondary_key_alias"; + + /** Name of the package to which the tertiary key belongs. */ + static final String COLUMN_NAME_PACKAGE_NAME = "package_name"; + + /** Encrypted bytes of the tertiary key. */ + static final String COLUMN_NAME_WRAPPED_KEY_BYTES = "wrapped_key_bytes"; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDbHelper.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDbHelper.java new file mode 100644 index 000000000000..c70634248dca --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/BackupEncryptionDbHelper.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2018 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.storage; + +import static com.android.server.backup.encryption.storage.BackupEncryptionDbContract.TertiaryKeysEntry; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; + +/** Helper for creating an instance of the backup encryption database. */ +class BackupEncryptionDbHelper extends SQLiteOpenHelper { + private static final int DATABASE_VERSION = 1; + static final String DATABASE_NAME = "backupencryption.db"; + + private static final String SQL_CREATE_TERTIARY_KEYS_ENTRY = + "CREATE TABLE " + + TertiaryKeysEntry.TABLE_NAME + + " ( " + + TertiaryKeysEntry._ID + + " INTEGER PRIMARY KEY," + + TertiaryKeysEntry.COLUMN_NAME_SECONDARY_KEY_ALIAS + + " TEXT," + + TertiaryKeysEntry.COLUMN_NAME_PACKAGE_NAME + + " TEXT," + + TertiaryKeysEntry.COLUMN_NAME_WRAPPED_KEY_BYTES + + " BLOB," + + "UNIQUE(" + + TertiaryKeysEntry.COLUMN_NAME_SECONDARY_KEY_ALIAS + + "," + + TertiaryKeysEntry.COLUMN_NAME_PACKAGE_NAME + + "))"; + + private static final String SQL_DROP_TERTIARY_KEYS_ENTRY = + "DROP TABLE IF EXISTS " + TertiaryKeysEntry.TABLE_NAME; + + BackupEncryptionDbHelper(Context context) { + super(context, DATABASE_NAME, /*factory=*/ null, DATABASE_VERSION); + } + + public void resetDatabase() throws EncryptionDbException { + SQLiteDatabase db = getWritableDatabaseSafe(); + db.execSQL(SQL_DROP_TERTIARY_KEYS_ENTRY); + onCreate(db); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(SQL_CREATE_TERTIARY_KEYS_ENTRY); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL(SQL_DROP_TERTIARY_KEYS_ENTRY); + onCreate(db); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL(SQL_DROP_TERTIARY_KEYS_ENTRY); + onCreate(db); + } + + /** + * Calls {@link #getWritableDatabase()}, but catches the unchecked {@link SQLiteException} and + * rethrows {@link EncryptionDbException}. + */ + public SQLiteDatabase getWritableDatabaseSafe() throws EncryptionDbException { + try { + return super.getWritableDatabase(); + } catch (SQLiteException e) { + throw new EncryptionDbException(e); + } + } + + /** + * Calls {@link #getReadableDatabase()}, but catches the unchecked {@link SQLiteException} and + * rethrows {@link EncryptionDbException}. + */ + public SQLiteDatabase getReadableDatabaseSafe() throws EncryptionDbException { + try { + return super.getReadableDatabase(); + } catch (SQLiteException e) { + throw new EncryptionDbException(e); + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/EncryptionDbException.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/EncryptionDbException.java new file mode 100644 index 000000000000..82f7dead1b50 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/EncryptionDbException.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2018 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.storage; + +import java.io.IOException; + +/** Thrown when there is a problem reading or writing the encryption database. */ +public class EncryptionDbException extends IOException { + public EncryptionDbException(Throwable cause) { + super(cause); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/TertiaryKey.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/TertiaryKey.java new file mode 100644 index 000000000000..39a2c6ebb9c3 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/TertiaryKey.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2018 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.storage; + +/** Wrapped bytes of a tertiary key. */ +public class TertiaryKey { + private final String mSecondaryKeyAlias; + private final String mPackageName; + private final byte[] mWrappedKeyBytes; + + /** + * Creates a new instance. + * + * @param secondaryKeyAlias Alias of the secondary used to wrap the key. + * @param packageName The package name of the app to which the key belongs. + * @param wrappedKeyBytes The wrapped key bytes. + */ + public TertiaryKey(String secondaryKeyAlias, String packageName, byte[] wrappedKeyBytes) { + mSecondaryKeyAlias = secondaryKeyAlias; + mPackageName = packageName; + mWrappedKeyBytes = wrappedKeyBytes; + } + + /** Returns the alias of the secondary key used to wrap this tertiary key. */ + public String getSecondaryKeyAlias() { + return mSecondaryKeyAlias; + } + + /** Returns the package name of the application this key relates to. */ + public String getPackageName() { + return mPackageName; + } + + /** Returns the wrapped bytes of the key. */ + public byte[] getWrappedKeyBytes() { + return mWrappedKeyBytes; + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/TertiaryKeysTable.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/TertiaryKeysTable.java new file mode 100644 index 000000000000..d8d40c402a84 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/storage/TertiaryKeysTable.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2018 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.storage; + +import static com.android.server.backup.encryption.storage.BackupEncryptionDbContract.TertiaryKeysEntry; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.util.ArrayMap; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +/** Database table for storing and retrieving tertiary keys. */ +public class TertiaryKeysTable { + private final BackupEncryptionDbHelper mHelper; + + TertiaryKeysTable(BackupEncryptionDbHelper helper) { + mHelper = helper; + } + + /** + * Adds the {@code tertiaryKey} to the database. + * + * @return The primary key of the inserted row if successful, -1 otherwise. + */ + public long addKey(TertiaryKey tertiaryKey) throws EncryptionDbException { + SQLiteDatabase db = mHelper.getWritableDatabaseSafe(); + ContentValues values = new ContentValues(); + values.put( + TertiaryKeysEntry.COLUMN_NAME_SECONDARY_KEY_ALIAS, + tertiaryKey.getSecondaryKeyAlias()); + values.put(TertiaryKeysEntry.COLUMN_NAME_PACKAGE_NAME, tertiaryKey.getPackageName()); + values.put( + TertiaryKeysEntry.COLUMN_NAME_WRAPPED_KEY_BYTES, tertiaryKey.getWrappedKeyBytes()); + return db.replace(TertiaryKeysEntry.TABLE_NAME, /*nullColumnHack=*/ null, values); + } + + /** Gets the key wrapped by {@code secondaryKeyAlias} for app with {@code packageName}. */ + public Optional<TertiaryKey> getKey(String secondaryKeyAlias, String packageName) + throws EncryptionDbException { + SQLiteDatabase db = mHelper.getReadableDatabaseSafe(); + String[] projection = { + TertiaryKeysEntry._ID, + TertiaryKeysEntry.COLUMN_NAME_SECONDARY_KEY_ALIAS, + TertiaryKeysEntry.COLUMN_NAME_PACKAGE_NAME, + TertiaryKeysEntry.COLUMN_NAME_WRAPPED_KEY_BYTES + }; + String selection = + TertiaryKeysEntry.COLUMN_NAME_SECONDARY_KEY_ALIAS + + " = ? AND " + + TertiaryKeysEntry.COLUMN_NAME_PACKAGE_NAME + + " = ?"; + String[] selectionArguments = {secondaryKeyAlias, packageName}; + + try (Cursor cursor = + db.query( + TertiaryKeysEntry.TABLE_NAME, + projection, + selection, + selectionArguments, + /*groupBy=*/ null, + /*having=*/ null, + /*orderBy=*/ null)) { + int count = cursor.getCount(); + if (count == 0) { + return Optional.empty(); + } + + cursor.moveToFirst(); + byte[] wrappedKeyBytes = + cursor.getBlob( + cursor.getColumnIndexOrThrow( + TertiaryKeysEntry.COLUMN_NAME_WRAPPED_KEY_BYTES)); + return Optional.of(new TertiaryKey(secondaryKeyAlias, packageName, wrappedKeyBytes)); + } + } + + /** Returns all keys wrapped with {@code tertiaryKeyAlias} as an unmodifiable map. */ + public Map<String, TertiaryKey> getAllKeys(String secondaryKeyAlias) + throws EncryptionDbException { + SQLiteDatabase db = mHelper.getReadableDatabaseSafe(); + String[] projection = { + TertiaryKeysEntry._ID, + TertiaryKeysEntry.COLUMN_NAME_SECONDARY_KEY_ALIAS, + TertiaryKeysEntry.COLUMN_NAME_PACKAGE_NAME, + TertiaryKeysEntry.COLUMN_NAME_WRAPPED_KEY_BYTES + }; + String selection = TertiaryKeysEntry.COLUMN_NAME_SECONDARY_KEY_ALIAS + " = ?"; + String[] selectionArguments = {secondaryKeyAlias}; + + Map<String, TertiaryKey> keysByPackageName = new ArrayMap<>(); + try (Cursor cursor = + db.query( + TertiaryKeysEntry.TABLE_NAME, + projection, + selection, + selectionArguments, + /*groupBy=*/ null, + /*having=*/ null, + /*orderBy=*/ null)) { + while (cursor.moveToNext()) { + String packageName = + cursor.getString( + cursor.getColumnIndexOrThrow( + TertiaryKeysEntry.COLUMN_NAME_PACKAGE_NAME)); + byte[] wrappedKeyBytes = + cursor.getBlob( + cursor.getColumnIndexOrThrow( + TertiaryKeysEntry.COLUMN_NAME_WRAPPED_KEY_BYTES)); + keysByPackageName.put( + packageName, + new TertiaryKey(secondaryKeyAlias, packageName, wrappedKeyBytes)); + } + } + return Collections.unmodifiableMap(keysByPackageName); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupEncrypter.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupEncrypter.java new file mode 100644 index 000000000000..95d0d97b4073 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupEncrypter.java @@ -0,0 +1,90 @@ +/* + * 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 java.util.Collections.unmodifiableList; + +import android.annotation.Nullable; + +import com.android.server.backup.encryption.chunk.ChunkHash; +import com.android.server.backup.encryption.chunking.EncryptedChunk; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import javax.crypto.SecretKey; + +/** Task which reads data from some source, splits it into chunks and encrypts new chunks. */ +public interface BackupEncrypter { + /** The algorithm which we use to compute the digest of the backup file plaintext. */ + String MESSAGE_DIGEST_ALGORITHM = "SHA-256"; + + /** + * Splits the backup input into encrypted chunks and encrypts new chunks. + * + * @param secretKey Key used to encrypt backup. + * @param fingerprintMixerSalt Fingerprint mixer salt used for content-defined chunking during a + * full backup. Should be {@code null} for a key-value backup. + * @param existingChunks Set of the SHA-256 Macs of chunks the server already has. + * @return a result containing an array of new encrypted chunks to upload, and an ordered + * listing of the chunks in the backup file. + * @throws IOException if a problem occurs reading from the backup data. + * @throws GeneralSecurityException if there is a problem encrypting the data. + */ + Result backup( + SecretKey secretKey, + @Nullable byte[] fingerprintMixerSalt, + Set<ChunkHash> existingChunks) + throws IOException, GeneralSecurityException; + + /** + * The result of an incremental backup. Contains new encrypted chunks to upload, and an ordered + * list of the chunks in the backup file. + */ + class Result { + private final List<ChunkHash> mAllChunks; + private final List<EncryptedChunk> mNewChunks; + private final byte[] mDigest; + + public Result(List<ChunkHash> allChunks, List<EncryptedChunk> newChunks, byte[] digest) { + mAllChunks = unmodifiableList(new ArrayList<>(allChunks)); + mDigest = digest; + mNewChunks = unmodifiableList(new ArrayList<>(newChunks)); + } + + /** + * Returns an unmodifiable list of the hashes of all the chunks in the backup, in the order + * they appear in the plaintext. + */ + public List<ChunkHash> getAllChunks() { + return mAllChunks; + } + + /** Returns an unmodifiable list of the new chunks in the backup. */ + public List<EncryptedChunk> getNewChunks() { + return mNewChunks; + } + + /** Returns the message digest of the backup. */ + public byte[] getDigest() { + return mDigest; + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypter.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypter.java new file mode 100644 index 000000000000..45798d32885a --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypter.java @@ -0,0 +1,127 @@ +/* + * 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 com.android.server.backup.encryption.chunk.ChunkHash; +import com.android.server.backup.encryption.chunking.ChunkEncryptor; +import com.android.server.backup.encryption.chunking.ChunkHasher; +import com.android.server.backup.encryption.chunking.EncryptedChunk; +import com.android.server.backup.encryption.chunking.cdc.ContentDefinedChunker; +import com.android.server.backup.encryption.chunking.cdc.FingerprintMixer; +import com.android.server.backup.encryption.chunking.cdc.IsChunkBreakpoint; +import com.android.server.backup.encryption.chunking.cdc.RabinFingerprint64; + +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.crypto.SecretKey; + +/** + * Splits backup data into variable-sized chunks using content-defined chunking, then encrypts the + * chunks. Given a hash of the SHA-256s of existing chunks, performs an incremental backup (i.e., + * only encrypts new chunks). + */ +public class BackupStreamEncrypter implements BackupEncrypter { + private static final String TAG = "BackupStreamEncryptor"; + + private final InputStream mData; + private final int mMinChunkSizeBytes; + private final int mMaxChunkSizeBytes; + private final int mAverageChunkSizeBytes; + + /** + * A new instance over the given distribution of chunk sizes. + * + * @param data The data to be backed up. + * @param minChunkSizeBytes The minimum chunk size. No chunk will be smaller than this. + * @param maxChunkSizeBytes The maximum chunk size. No chunk will be larger than this. + * @param averageChunkSizeBytes The average chunk size. The mean size of chunks will be roughly + * this (with a few tens of bytes of overhead for the initialization vector and message + * authentication code). + */ + public BackupStreamEncrypter( + InputStream data, + int minChunkSizeBytes, + int maxChunkSizeBytes, + int averageChunkSizeBytes) { + this.mData = data; + this.mMinChunkSizeBytes = minChunkSizeBytes; + this.mMaxChunkSizeBytes = maxChunkSizeBytes; + this.mAverageChunkSizeBytes = averageChunkSizeBytes; + } + + @Override + public Result backup( + SecretKey secretKey, byte[] fingerprintMixerSalt, Set<ChunkHash> existingChunks) + throws IOException, GeneralSecurityException { + MessageDigest messageDigest = + MessageDigest.getInstance(BackupEncrypter.MESSAGE_DIGEST_ALGORITHM); + RabinFingerprint64 rabinFingerprint64 = new RabinFingerprint64(); + FingerprintMixer fingerprintMixer = new FingerprintMixer(secretKey, fingerprintMixerSalt); + IsChunkBreakpoint isChunkBreakpoint = + new IsChunkBreakpoint(mAverageChunkSizeBytes - mMinChunkSizeBytes); + ContentDefinedChunker chunker = + new ContentDefinedChunker( + mMinChunkSizeBytes, + mMaxChunkSizeBytes, + rabinFingerprint64, + fingerprintMixer, + isChunkBreakpoint); + ChunkHasher chunkHasher = new ChunkHasher(secretKey); + ChunkEncryptor encryptor = new ChunkEncryptor(secretKey, new SecureRandom()); + Set<ChunkHash> includedChunks = new HashSet<>(); + // New chunks will be added only once to this list, even if they occur multiple times. + List<EncryptedChunk> newChunks = new ArrayList<>(); + // All chunks (including multiple occurrences) will be added to the chunkListing. + List<ChunkHash> chunkListing = new ArrayList<>(); + + includedChunks.addAll(existingChunks); + + chunker.chunkify( + mData, + chunk -> { + messageDigest.update(chunk); + ChunkHash key = chunkHasher.computeHash(chunk); + + if (!includedChunks.contains(key)) { + newChunks.add(encryptor.encrypt(key, chunk)); + includedChunks.add(key); + } + chunkListing.add(key); + }); + + Slog.i( + TAG, + String.format( + "Chunks: %d total, %d unique, %d new", + chunkListing.size(), new HashSet<>(chunkListing).size(), newChunks.size())); + return new Result( + Collections.unmodifiableList(chunkListing), + Collections.unmodifiableList(newChunks), + messageDigest.digest()); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/DecryptedChunkOutput.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/DecryptedChunkOutput.java new file mode 100644 index 000000000000..e3df3c1eb96f --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/DecryptedChunkOutput.java @@ -0,0 +1,54 @@ +/* + * 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 java.io.Closeable; +import java.io.IOException; +import java.security.InvalidKeyException; + +/** + * Accepts the plaintext bytes of decrypted chunks and writes them to some output. Also keeps track + * of the message digest of the chunks. + */ +public interface DecryptedChunkOutput extends Closeable { + /** + * Opens whatever output the implementation chooses, ready to process chunks. + * + * @return {@code this}, to allow use with try-with-resources + */ + DecryptedChunkOutput open() throws IOException; + + /** + * Writes the plaintext bytes of chunk to whatever output the implementation chooses. Also + * updates the digest with the chunk. + * + * <p>You must call {@link #open()} before this method, and you may not call it after calling + * {@link Closeable#close()}. + * + * @param plaintextBuffer An array containing the bytes of the plaintext of the chunk, starting + * at index 0. + * @param length The length in bytes of the plaintext contained in {@code plaintextBuffer}. + */ + void processChunk(byte[] plaintextBuffer, int length) throws IOException, InvalidKeyException; + + /** + * Returns the message digest of all the chunks processed by {@link #processChunk}. + * + * <p>You must call {@link Closeable#close()} before calling this method. + */ + byte[] getDigest(); +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedRestoreException.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedRestoreException.java new file mode 100644 index 000000000000..487c0d92f6fd --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedRestoreException.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** Wraps any exception related to encryption which occurs during restore. */ +public class EncryptedRestoreException extends Exception { + public EncryptedRestoreException(String message) { + super(message); + } + + public EncryptedRestoreException(Throwable cause) { + super(cause); + } + + public EncryptedRestoreException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/packages/BackupEncryption/test/robolectric/Android.bp b/packages/BackupEncryption/test/robolectric/Android.bp new file mode 100644 index 000000000000..4e42ce7366f0 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/Android.bp @@ -0,0 +1,39 @@ +// Copyright (C) 2018 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. + +android_robolectric_test { + name: "BackupEncryptionRoboTests", + srcs: [ + "src/**/*.java", + ":FrameworksServicesRoboShadows", + ], + java_resource_dirs: ["config"], + libs: [ + "backup-encryption-protos", + "platform-test-annotations", + "testng", + "truth-prebuilt", + ], + static_libs: [ + "androidx.test.core", + "androidx.test.runner", + "androidx.test.rules", + ], + instrumentation_for: "BackupEncryption", +} + +filegroup { + name: "BackupEncryptionRoboShadows", + srcs: ["src/com/android/server/testing/shadows/**/*.java"], +} diff --git a/packages/BackupEncryption/test/robolectric/AndroidManifest.xml b/packages/BackupEncryption/test/robolectric/AndroidManifest.xml new file mode 100644 index 000000000000..ae5cdd918abd --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/AndroidManifest.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + coreApp="true" + package="com.android.server.backup.encryption.robotests"> + + <application/> + +</manifest> diff --git a/packages/BackupEncryption/test/robolectric/config/robolectric.properties b/packages/BackupEncryption/test/robolectric/config/robolectric.properties new file mode 100644 index 000000000000..26fceb3f84a4 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/config/robolectric.properties @@ -0,0 +1,17 @@ +# +# 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. +# + +sdk=NEWEST_SDK diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/CryptoSettingsTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/CryptoSettingsTest.java new file mode 100644 index 000000000000..979b3d5dc13a --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/CryptoSettingsTest.java @@ -0,0 +1,212 @@ +/* + * 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; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.app.Application; +import android.security.keystore.recovery.RecoveryController; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.server.testing.shadows.ShadowRecoveryController; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Optional; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = ShadowRecoveryController.class) +public class CryptoSettingsTest { + + private static final String TEST_KEY_ALIAS = + "com.android.server.backup.encryption/keystore/08120c326b928ff34c73b9c58581da63"; + + private CryptoSettings mCryptoSettings; + private Application mApplication; + + @Before + public void setUp() { + ShadowRecoveryController.reset(); + + mApplication = ApplicationProvider.getApplicationContext(); + mCryptoSettings = CryptoSettings.getInstanceForTesting(mApplication); + } + + @Test + public void getActiveSecondaryAlias_isInitiallyAbsent() { + assertThat(mCryptoSettings.getActiveSecondaryKeyAlias().isPresent()).isFalse(); + } + + @Test + public void getActiveSecondaryAlias_returnsAliasIfKeyIsInRecoveryController() throws Exception { + setAliasIsInRecoveryController(TEST_KEY_ALIAS); + mCryptoSettings.setActiveSecondaryKeyAlias(TEST_KEY_ALIAS); + assertThat(mCryptoSettings.getActiveSecondaryKeyAlias().get()).isEqualTo(TEST_KEY_ALIAS); + } + + @Test + public void getNextSecondaryAlias_isInitiallyAbsent() { + assertThat(mCryptoSettings.getNextSecondaryKeyAlias().isPresent()).isFalse(); + } + + @Test + public void getNextSecondaryAlias_returnsAliasIfKeyIsInRecoveryController() throws Exception { + setAliasIsInRecoveryController(TEST_KEY_ALIAS); + mCryptoSettings.setNextSecondaryAlias(TEST_KEY_ALIAS); + assertThat(mCryptoSettings.getNextSecondaryKeyAlias().get()).isEqualTo(TEST_KEY_ALIAS); + } + + @Test + public void isInitialized_isInitiallyFalse() { + assertThat(mCryptoSettings.getIsInitialized()).isFalse(); + } + + @Test + public void setActiveSecondaryAlias_throwsIfKeyIsNotInRecoveryController() { + assertThrows( + IllegalArgumentException.class, + () -> mCryptoSettings.setActiveSecondaryKeyAlias(TEST_KEY_ALIAS)); + } + + @Test + public void setNextSecondaryAlias_inRecoveryController_setsAlias() throws Exception { + setAliasIsInRecoveryController(TEST_KEY_ALIAS); + + mCryptoSettings.setNextSecondaryAlias(TEST_KEY_ALIAS); + + assertThat(mCryptoSettings.getNextSecondaryKeyAlias().get()).isEqualTo(TEST_KEY_ALIAS); + } + + @Test + public void setNextSecondaryAlias_throwsIfKeyIsNotInRecoveryController() { + assertThrows( + IllegalArgumentException.class, + () -> mCryptoSettings.setNextSecondaryAlias(TEST_KEY_ALIAS)); + } + + @Test + public void removeNextSecondaryAlias_removesIt() throws Exception { + setAliasIsInRecoveryController(TEST_KEY_ALIAS); + mCryptoSettings.setNextSecondaryAlias(TEST_KEY_ALIAS); + + mCryptoSettings.removeNextSecondaryKeyAlias(); + + assertThat(mCryptoSettings.getNextSecondaryKeyAlias().isPresent()).isFalse(); + } + + @Test + public void initializeWithKeyAlias_setsAsInitialized() throws Exception { + setAliasIsInRecoveryController(TEST_KEY_ALIAS); + mCryptoSettings.initializeWithKeyAlias(TEST_KEY_ALIAS); + assertThat(mCryptoSettings.getIsInitialized()).isTrue(); + } + + @Test + public void initializeWithKeyAlias_setsActiveAlias() throws Exception { + setAliasIsInRecoveryController(TEST_KEY_ALIAS); + mCryptoSettings.initializeWithKeyAlias(TEST_KEY_ALIAS); + assertThat(mCryptoSettings.getActiveSecondaryKeyAlias().get()).isEqualTo(TEST_KEY_ALIAS); + } + + @Test + public void initializeWithKeyAlias_throwsIfKeyIsNotInRecoveryController() { + assertThrows( + IllegalArgumentException.class, + () -> mCryptoSettings.initializeWithKeyAlias(TEST_KEY_ALIAS)); + } + + @Test + public void initializeWithKeyAlias_throwsIfAlreadyInitialized() throws Exception { + setAliasIsInRecoveryController(TEST_KEY_ALIAS); + mCryptoSettings.initializeWithKeyAlias(TEST_KEY_ALIAS); + + assertThrows( + IllegalStateException.class, + () -> mCryptoSettings.initializeWithKeyAlias(TEST_KEY_ALIAS)); + } + + @Test + public void getSecondaryLastRotated_returnsEmptyInitially() { + assertThat(mCryptoSettings.getSecondaryLastRotated()).isEqualTo(Optional.empty()); + } + + @Test + public void getSecondaryLastRotated_returnsTimestampAfterItIsSet() { + long timestamp = 1000001; + + mCryptoSettings.setSecondaryLastRotated(timestamp); + + assertThat(mCryptoSettings.getSecondaryLastRotated().get()).isEqualTo(timestamp); + } + + @Test + public void getAncestralSecondaryKeyVersion_notSet_returnsOptionalAbsent() { + assertThat(mCryptoSettings.getAncestralSecondaryKeyVersion().isPresent()).isFalse(); + } + + @Test + public void getAncestralSecondaryKeyVersion_isSet_returnsSetValue() { + String secondaryKeyVersion = "some_secondary_key"; + mCryptoSettings.setAncestralSecondaryKeyVersion(secondaryKeyVersion); + + assertThat(mCryptoSettings.getAncestralSecondaryKeyVersion().get()) + .isEqualTo(secondaryKeyVersion); + } + + @Test + public void getAncestralSecondaryKeyVersion_isSetMultipleTimes_returnsLastSetValue() { + String secondaryKeyVersion1 = "some_secondary_key"; + String secondaryKeyVersion2 = "another_secondary_key"; + mCryptoSettings.setAncestralSecondaryKeyVersion(secondaryKeyVersion1); + mCryptoSettings.setAncestralSecondaryKeyVersion(secondaryKeyVersion2); + + assertThat(mCryptoSettings.getAncestralSecondaryKeyVersion().get()) + .isEqualTo(secondaryKeyVersion2); + } + + @Test + public void clearAllSettingsForBackup_clearsStateForBackup() throws Exception { + String key1 = "key1"; + String key2 = "key2"; + String ancestralKey = "ancestral_key"; + setAliasIsInRecoveryController(key1); + setAliasIsInRecoveryController(key2); + mCryptoSettings.setActiveSecondaryKeyAlias(key1); + mCryptoSettings.setNextSecondaryAlias(key2); + mCryptoSettings.setSecondaryLastRotated(100001); + mCryptoSettings.setAncestralSecondaryKeyVersion(ancestralKey); + + mCryptoSettings.clearAllSettingsForBackup(); + + assertThat(mCryptoSettings.getActiveSecondaryKeyAlias().isPresent()).isFalse(); + assertThat(mCryptoSettings.getNextSecondaryKeyAlias().isPresent()).isFalse(); + assertThat(mCryptoSettings.getSecondaryLastRotated().isPresent()).isFalse(); + assertThat(mCryptoSettings.getAncestralSecondaryKeyVersion().get()).isEqualTo(ancestralKey); + } + + private void setAliasIsInRecoveryController(String alias) throws Exception { + RecoveryController recoveryController = RecoveryController.getInstance(mApplication); + recoveryController.generateKey(alias); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkHashTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkHashTest.java new file mode 100644 index 000000000000..c12464c50175 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkHashTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2018 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.chunk; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import com.google.common.primitives.Bytes; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.Arrays; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class ChunkHashTest { + private static final int HASH_LENGTH_BYTES = 256 / 8; + private static final byte[] TEST_HASH_1 = Arrays.copyOf(new byte[] {1}, HASH_LENGTH_BYTES); + private static final byte[] TEST_HASH_2 = Arrays.copyOf(new byte[] {2}, HASH_LENGTH_BYTES); + + @Test + public void testGetHash_returnsHash() { + ChunkHash chunkHash = new ChunkHash(TEST_HASH_1); + + byte[] hash = chunkHash.getHash(); + + assertThat(hash).asList().containsExactlyElementsIn(Bytes.asList(TEST_HASH_1)).inOrder(); + } + + @Test + public void testEquals() { + ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1); + ChunkHash equalChunkHash1 = new ChunkHash(TEST_HASH_1); + ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2); + + assertThat(chunkHash1).isEqualTo(equalChunkHash1); + assertThat(chunkHash1).isNotEqualTo(chunkHash2); + } + + @Test + public void testHashCode() { + ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1); + ChunkHash equalChunkHash1 = new ChunkHash(TEST_HASH_1); + ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2); + + int hash1 = chunkHash1.hashCode(); + int equalHash1 = equalChunkHash1.hashCode(); + int hash2 = chunkHash2.hashCode(); + + assertThat(hash1).isEqualTo(equalHash1); + assertThat(hash1).isNotEqualTo(hash2); + } + + @Test + public void testCompareTo_whenEqual_returnsZero() { + ChunkHash chunkHash = new ChunkHash(TEST_HASH_1); + ChunkHash equalChunkHash = new ChunkHash(TEST_HASH_1); + + int result = chunkHash.compareTo(equalChunkHash); + + assertThat(result).isEqualTo(0); + } + + @Test + public void testCompareTo_whenArgumentGreater_returnsNegative() { + ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1); + ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2); + + int result = chunkHash1.compareTo(chunkHash2); + + assertThat(result).isLessThan(0); + } + + @Test + public void testCompareTo_whenArgumentSmaller_returnsPositive() { + ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1); + ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2); + + int result = chunkHash2.compareTo(chunkHash1); + + assertThat(result).isGreaterThan(0); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkListingMapTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkListingMapTest.java new file mode 100644 index 000000000000..24e5573b891d --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkListingMapTest.java @@ -0,0 +1,197 @@ +/* + * 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.chunk; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; +import android.util.proto.ProtoInputStream; +import android.util.proto.ProtoOutputStream; + +import com.android.internal.util.Preconditions; + +import com.google.common.base.Charsets; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayInputStream; +import java.util.Arrays; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class ChunkListingMapTest { + private static final String CHUNK_A = "CHUNK_A"; + private static final String CHUNK_B = "CHUNK_B"; + private static final String CHUNK_C = "CHUNK_C"; + + private static final int CHUNK_A_LENGTH = 256; + private static final int CHUNK_B_LENGTH = 1024; + private static final int CHUNK_C_LENGTH = 4055; + + private ChunkHash mChunkHashA; + private ChunkHash mChunkHashB; + private ChunkHash mChunkHashC; + + @Before + public void setUp() throws Exception { + mChunkHashA = getHash(CHUNK_A); + mChunkHashB = getHash(CHUNK_B); + mChunkHashC = getHash(CHUNK_C); + } + + @Test + public void testHasChunk_whenChunkInListing_returnsTrue() throws Exception { + byte[] chunkListingProto = + createChunkListingProto( + new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC}, + new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH}); + ChunkListingMap chunkListingMap = + ChunkListingMap.readFromProto( + new ProtoInputStream(new ByteArrayInputStream(chunkListingProto))); + + boolean chunkAInList = chunkListingMap.hasChunk(mChunkHashA); + boolean chunkBInList = chunkListingMap.hasChunk(mChunkHashB); + boolean chunkCInList = chunkListingMap.hasChunk(mChunkHashC); + + assertThat(chunkAInList).isTrue(); + assertThat(chunkBInList).isTrue(); + assertThat(chunkCInList).isTrue(); + } + + @Test + public void testHasChunk_whenChunkNotInListing_returnsFalse() throws Exception { + byte[] chunkListingProto = + createChunkListingProto( + new ChunkHash[] {mChunkHashA, mChunkHashB}, + new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH}); + ChunkListingMap chunkListingMap = + ChunkListingMap.readFromProto( + new ProtoInputStream(new ByteArrayInputStream(chunkListingProto))); + ChunkHash chunkHashEmpty = getHash(""); + + boolean chunkCInList = chunkListingMap.hasChunk(mChunkHashC); + boolean emptyChunkInList = chunkListingMap.hasChunk(chunkHashEmpty); + + assertThat(chunkCInList).isFalse(); + assertThat(emptyChunkInList).isFalse(); + } + + @Test + public void testGetChunkEntry_returnsEntryWithCorrectLength() throws Exception { + byte[] chunkListingProto = + createChunkListingProto( + new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC}, + new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH}); + ChunkListingMap chunkListingMap = + ChunkListingMap.readFromProto( + new ProtoInputStream(new ByteArrayInputStream(chunkListingProto))); + + ChunkListingMap.Entry entryA = chunkListingMap.getChunkEntry(mChunkHashA); + ChunkListingMap.Entry entryB = chunkListingMap.getChunkEntry(mChunkHashB); + ChunkListingMap.Entry entryC = chunkListingMap.getChunkEntry(mChunkHashC); + + assertThat(entryA.getLength()).isEqualTo(CHUNK_A_LENGTH); + assertThat(entryB.getLength()).isEqualTo(CHUNK_B_LENGTH); + assertThat(entryC.getLength()).isEqualTo(CHUNK_C_LENGTH); + } + + @Test + public void testGetChunkEntry_returnsEntryWithCorrectStart() throws Exception { + byte[] chunkListingProto = + createChunkListingProto( + new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC}, + new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH}); + ChunkListingMap chunkListingMap = + ChunkListingMap.readFromProto( + new ProtoInputStream(new ByteArrayInputStream(chunkListingProto))); + + ChunkListingMap.Entry entryA = chunkListingMap.getChunkEntry(mChunkHashA); + ChunkListingMap.Entry entryB = chunkListingMap.getChunkEntry(mChunkHashB); + ChunkListingMap.Entry entryC = chunkListingMap.getChunkEntry(mChunkHashC); + + assertThat(entryA.getStart()).isEqualTo(0); + assertThat(entryB.getStart()).isEqualTo(CHUNK_A_LENGTH); + assertThat(entryC.getStart()).isEqualTo(CHUNK_A_LENGTH + CHUNK_B_LENGTH); + } + + @Test + public void testGetChunkEntry_returnsNullForNonExistentChunk() throws Exception { + byte[] chunkListingProto = + createChunkListingProto( + new ChunkHash[] {mChunkHashA, mChunkHashB}, + new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH}); + ChunkListingMap chunkListingMap = + ChunkListingMap.readFromProto( + new ProtoInputStream(new ByteArrayInputStream(chunkListingProto))); + + ChunkListingMap.Entry chunkEntryNonexistentChunk = + chunkListingMap.getChunkEntry(mChunkHashC); + + assertThat(chunkEntryNonexistentChunk).isNull(); + } + + @Test + public void testReadFromProto_whenEmptyProto_returnsChunkListingMapWith0Chunks() + throws Exception { + ProtoInputStream emptyProto = new ProtoInputStream(new ByteArrayInputStream(new byte[] {})); + + ChunkListingMap chunkListingMap = ChunkListingMap.readFromProto(emptyProto); + + assertThat(chunkListingMap.getChunkCount()).isEqualTo(0); + } + + @Test + public void testReadFromProto_returnsChunkListingWithCorrectSize() throws Exception { + byte[] chunkListingProto = + createChunkListingProto( + new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC}, + new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH}); + + ChunkListingMap chunkListingMap = + ChunkListingMap.readFromProto( + new ProtoInputStream(new ByteArrayInputStream(chunkListingProto))); + + assertThat(chunkListingMap.getChunkCount()).isEqualTo(3); + } + + private byte[] createChunkListingProto(ChunkHash[] hashes, int[] lengths) { + Preconditions.checkArgument(hashes.length == lengths.length); + ProtoOutputStream outputStream = new ProtoOutputStream(); + + for (int i = 0; i < hashes.length; ++i) { + writeToProtoOutputStream(outputStream, hashes[i], lengths[i]); + } + outputStream.flush(); + + return outputStream.getBytes(); + } + + private void writeToProtoOutputStream(ProtoOutputStream out, ChunkHash chunkHash, int length) { + long token = out.start(ChunksMetadataProto.ChunkListing.CHUNKS); + out.write(ChunksMetadataProto.Chunk.HASH, chunkHash.getHash()); + out.write(ChunksMetadataProto.Chunk.LENGTH, length); + out.end(token); + } + + private ChunkHash getHash(String name) { + return new ChunkHash( + Arrays.copyOf(name.getBytes(Charsets.UTF_8), ChunkHash.HASH_LENGTH_BYTES)); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkTest.java new file mode 100644 index 000000000000..1796f56ce17a --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2018 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.chunk; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; +import android.util.proto.ProtoInputStream; +import android.util.proto.ProtoOutputStream; + +import com.google.common.base.Charsets; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayInputStream; +import java.util.Arrays; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class ChunkTest { + private static final String CHUNK_A = "CHUNK_A"; + private static final int CHUNK_A_LENGTH = 256; + + private ChunkHash mChunkHashA; + + @Before + public void setUp() throws Exception { + mChunkHashA = getHash(CHUNK_A); + } + + @Test + public void testReadFromProto_readsCorrectly() throws Exception { + ProtoOutputStream out = new ProtoOutputStream(); + out.write(ChunksMetadataProto.Chunk.HASH, mChunkHashA.getHash()); + out.write(ChunksMetadataProto.Chunk.LENGTH, CHUNK_A_LENGTH); + out.flush(); + byte[] protoBytes = out.getBytes(); + + Chunk chunk = + Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes))); + + assertThat(chunk.getHash()).isEqualTo(mChunkHashA.getHash()); + assertThat(chunk.getLength()).isEqualTo(CHUNK_A_LENGTH); + } + + @Test + public void testReadFromProto_whenFieldsWrittenInReversedOrder_readsCorrectly() + throws Exception { + ProtoOutputStream out = new ProtoOutputStream(); + // Write fields of Chunk proto in reverse order. + out.write(ChunksMetadataProto.Chunk.LENGTH, CHUNK_A_LENGTH); + out.write(ChunksMetadataProto.Chunk.HASH, mChunkHashA.getHash()); + out.flush(); + byte[] protoBytes = out.getBytes(); + + Chunk chunk = + Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes))); + + assertThat(chunk.getHash()).isEqualTo(mChunkHashA.getHash()); + assertThat(chunk.getLength()).isEqualTo(CHUNK_A_LENGTH); + } + + @Test + public void testReadFromProto_whenEmptyProto_returnsEmptyHash() throws Exception { + ProtoInputStream emptyProto = new ProtoInputStream(new ByteArrayInputStream(new byte[] {})); + + Chunk chunk = Chunk.readFromProto(emptyProto); + + assertThat(chunk.getHash()).asList().hasSize(0); + assertThat(chunk.getLength()).isEqualTo(0); + } + + @Test + public void testReadFromProto_whenOnlyHashSet_returnsChunkWithOnlyHash() throws Exception { + ProtoOutputStream out = new ProtoOutputStream(); + out.write(ChunksMetadataProto.Chunk.HASH, mChunkHashA.getHash()); + out.flush(); + byte[] protoBytes = out.getBytes(); + + Chunk chunk = + Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes))); + + assertThat(chunk.getHash()).isEqualTo(mChunkHashA.getHash()); + assertThat(chunk.getLength()).isEqualTo(0); + } + + @Test + public void testReadFromProto_whenOnlyLengthSet_returnsChunkWithOnlyLength() throws Exception { + ProtoOutputStream out = new ProtoOutputStream(); + out.write(ChunksMetadataProto.Chunk.LENGTH, CHUNK_A_LENGTH); + out.flush(); + byte[] protoBytes = out.getBytes(); + + Chunk chunk = + Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes))); + + assertThat(chunk.getHash()).isEqualTo(new byte[] {}); + assertThat(chunk.getLength()).isEqualTo(CHUNK_A_LENGTH); + } + + private ChunkHash getHash(String name) { + return new ChunkHash( + Arrays.copyOf(name.getBytes(Charsets.UTF_8), ChunkHash.HASH_LENGTH_BYTES)); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrderingTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrderingTest.java new file mode 100644 index 000000000000..c6b29b7b7236 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrderingTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2018 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.chunk; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import com.google.common.primitives.Bytes; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class EncryptedChunkOrderingTest { + private static final byte[] TEST_BYTE_ARRAY_1 = new byte[] {1, 2, 3, 4, 5}; + private static final byte[] TEST_BYTE_ARRAY_2 = new byte[] {5, 4, 3, 2, 1}; + + @Test + public void testEncryptedChunkOrdering_returnsValue() { + EncryptedChunkOrdering encryptedChunkOrdering = + EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1); + + byte[] bytes = encryptedChunkOrdering.encryptedChunkOrdering(); + + assertThat(bytes) + .asList() + .containsExactlyElementsIn(Bytes.asList(TEST_BYTE_ARRAY_1)) + .inOrder(); + } + + @Test + public void testEquals() { + EncryptedChunkOrdering chunkOrdering1 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1); + EncryptedChunkOrdering equalChunkOrdering1 = + EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1); + EncryptedChunkOrdering chunkOrdering2 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_2); + + assertThat(chunkOrdering1).isEqualTo(equalChunkOrdering1); + assertThat(chunkOrdering1).isNotEqualTo(chunkOrdering2); + } + + @Test + public void testHashCode() { + EncryptedChunkOrdering chunkOrdering1 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1); + EncryptedChunkOrdering equalChunkOrdering1 = + EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1); + EncryptedChunkOrdering chunkOrdering2 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_2); + + int hash1 = chunkOrdering1.hashCode(); + int equalHash1 = equalChunkOrdering1.hashCode(); + int hash2 = chunkOrdering2.hashCode(); + + assertThat(hash1).isEqualTo(equalHash1); + assertThat(hash1).isNotEqualTo(hash2); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ByteRangeTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ByteRangeTest.java new file mode 100644 index 000000000000..8df08262c9fa --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ByteRangeTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static org.junit.Assert.assertEquals; +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link ByteRange}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class ByteRangeTest { + @Test + public void getLength_includesEnd() throws Exception { + ByteRange byteRange = new ByteRange(5, 10); + + int length = byteRange.getLength(); + + assertEquals(6, length); + } + + @Test + public void constructor_rejectsNegativeStart() { + assertThrows(IllegalArgumentException.class, () -> new ByteRange(-1, 10)); + } + + @Test + public void constructor_rejectsEndBeforeStart() { + assertThrows(IllegalArgumentException.class, () -> new ByteRange(10, 9)); + } + + @Test + public void extend_withZeroLength_throwsException() { + ByteRange byteRange = new ByteRange(5, 10); + + assertThrows(IllegalArgumentException.class, () -> byteRange.extend(0)); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkEncryptorTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkEncryptorTest.java new file mode 100644 index 000000000000..19e3b28f85e7 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkEncryptorTest.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.chunk.ChunkHash; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; + +import java.security.SecureRandom; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class ChunkEncryptorTest { + private static final String MAC_ALGORITHM = "HmacSHA256"; + 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 String CHUNK_PLAINTEXT = + "A little Learning is a dang'rous Thing;\n" + + "Drink deep, or taste not the Pierian Spring:\n" + + "There shallow Draughts intoxicate the Brain,\n" + + "And drinking largely sobers us again."; + private static final byte[] PLAINTEXT_BYTES = CHUNK_PLAINTEXT.getBytes(UTF_8); + private static final byte[] NONCE_1 = "0123456789abc".getBytes(UTF_8); + private static final byte[] NONCE_2 = "123456789abcd".getBytes(UTF_8); + + private static final byte[][] NONCES = new byte[][] {NONCE_1, NONCE_2}; + + @Mock private SecureRandom mSecureRandomMock; + private SecretKey mSecretKey; + private ChunkHash mPlaintextHash; + private ChunkEncryptor mChunkEncryptor; + + @Before + public void setUp() throws Exception { + mSecretKey = generateAesKey(); + ChunkHasher chunkHasher = new ChunkHasher(mSecretKey); + mPlaintextHash = chunkHasher.computeHash(PLAINTEXT_BYTES); + mSecureRandomMock = mock(SecureRandom.class); + mChunkEncryptor = new ChunkEncryptor(mSecretKey, mSecureRandomMock); + + // Return NONCE_1, then NONCE_2 for invocations of mSecureRandomMock.nextBytes(). + doAnswer( + new Answer<Void>() { + private int mInvocation = 0; + + @Override + public Void answer(InvocationOnMock invocation) { + byte[] nonceDestination = invocation.getArgument(0); + System.arraycopy( + NONCES[this.mInvocation], + 0, + nonceDestination, + 0, + GCM_NONCE_LENGTH_BYTES); + this.mInvocation++; + return null; + } + }) + .when(mSecureRandomMock) + .nextBytes(any(byte[].class)); + } + + @Test + public void encrypt_withHash_resultContainsHashAsKey() throws Exception { + EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + assertThat(chunk.key()).isEqualTo(mPlaintextHash); + } + + @Test + public void encrypt_generatesHmacOfPlaintext() throws Exception { + EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + byte[] generatedHash = chunk.key().getHash(); + Mac mac = Mac.getInstance(MAC_ALGORITHM); + mac.init(mSecretKey); + byte[] plaintextHmac = mac.doFinal(PLAINTEXT_BYTES); + assertThat(generatedHash).isEqualTo(plaintextHmac); + } + + @Test + public void encrypt_whenInvokedAgain_generatesNewNonce() throws Exception { + EncryptedChunk chunk1 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + EncryptedChunk chunk2 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + assertThat(chunk1.nonce()).isNotEqualTo(chunk2.nonce()); + } + + @Test + public void encrypt_whenInvokedAgain_generatesNewCiphertext() throws Exception { + EncryptedChunk chunk1 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + EncryptedChunk chunk2 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + assertThat(chunk1.encryptedBytes()).isNotEqualTo(chunk2.encryptedBytes()); + } + + @Test + public void encrypt_generates12ByteNonce() throws Exception { + EncryptedChunk encryptedChunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + byte[] nonce = encryptedChunk.nonce(); + assertThat(nonce).hasLength(GCM_NONCE_LENGTH_BYTES); + } + + @Test + public void encrypt_decryptedResultCorrespondsToPlaintext() throws Exception { + EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES); + + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + cipher.init( + Cipher.DECRYPT_MODE, + mSecretKey, + new GCMParameterSpec(GCM_TAG_LENGTH_BYTES * 8, chunk.nonce())); + byte[] decrypted = cipher.doFinal(chunk.encryptedBytes()); + assertThat(decrypted).isEqualTo(PLAINTEXT_BYTES); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkHasherTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkHasherTest.java new file mode 100644 index 000000000000..72a927db743d --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkHasherTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.chunk.ChunkHash; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class ChunkHasherTest { + private static final String KEY_ALGORITHM = "AES"; + private static final String MAC_ALGORITHM = "HmacSHA256"; + + private static final byte[] TEST_KEY = {100, 120}; + private static final byte[] TEST_DATA = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; + + private SecretKey mSecretKey; + private ChunkHasher mChunkHasher; + + @Before + public void setUp() throws Exception { + mSecretKey = new SecretKeySpec(TEST_KEY, KEY_ALGORITHM); + mChunkHasher = new ChunkHasher(mSecretKey); + } + + @Test + public void computeHash_returnsHmacForData() throws Exception { + ChunkHash chunkHash = mChunkHasher.computeHash(TEST_DATA); + + byte[] hash = chunkHash.getHash(); + Mac mac = Mac.getInstance(MAC_ALGORITHM); + mac.init(mSecretKey); + byte[] expectedHash = mac.doFinal(TEST_DATA); + assertThat(hash).isEqualTo(expectedHash); + } + + @Test + public void computeHash_generates256BitHmac() throws Exception { + int expectedLength = 256 / Byte.SIZE; + + byte[] hash = mChunkHasher.computeHash(TEST_DATA).getHash(); + + assertThat(hash).hasLength(expectedLength); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutputTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutputTest.java new file mode 100644 index 000000000000..823a63c22da4 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutputTest.java @@ -0,0 +1,134 @@ +/* + * 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.tasks.DecryptedChunkOutput; + +import com.google.common.io.Files; +import com.google.common.primitives.Bytes; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.security.MessageDigest; +import java.util.Arrays; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class DecryptedChunkFileOutputTest { + private static final byte[] TEST_CHUNK_1 = {1, 2, 3}; + private static final byte[] TEST_CHUNK_2 = {4, 5, 6, 7, 8, 9, 10}; + private static final int TEST_BUFFER_LENGTH = + Math.max(TEST_CHUNK_1.length, TEST_CHUNK_2.length); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private File mOutputFile; + private DecryptedChunkFileOutput mDecryptedChunkFileOutput; + + @Before + public void setUp() throws Exception { + mOutputFile = temporaryFolder.newFile(); + mDecryptedChunkFileOutput = new DecryptedChunkFileOutput(mOutputFile); + } + + @Test + public void open_returnsInstance() throws Exception { + DecryptedChunkOutput result = mDecryptedChunkFileOutput.open(); + assertThat(result).isEqualTo(mDecryptedChunkFileOutput); + } + + @Test + public void open_nonExistentOutputFolder_throwsException() throws Exception { + mDecryptedChunkFileOutput = + new DecryptedChunkFileOutput( + new File(temporaryFolder.newFolder(), "mOutput/directory")); + assertThrows(FileNotFoundException.class, () -> mDecryptedChunkFileOutput.open()); + } + + @Test + public void open_whenRunTwice_throwsException() throws Exception { + mDecryptedChunkFileOutput.open(); + assertThrows(IllegalStateException.class, () -> mDecryptedChunkFileOutput.open()); + } + + @Test + public void processChunk_beforeOpen_throwsException() throws Exception { + assertThrows(IllegalStateException.class, + () -> mDecryptedChunkFileOutput.processChunk(new byte[0], 0)); + } + + @Test + public void processChunk_writesChunksToFile() throws Exception { + processTestChunks(); + + assertThat(Files.toByteArray(mOutputFile)) + .isEqualTo(Bytes.concat(TEST_CHUNK_1, TEST_CHUNK_2)); + } + + @Test + public void getDigest_beforeClose_throws() throws Exception { + mDecryptedChunkFileOutput.open(); + assertThrows(IllegalStateException.class, () -> mDecryptedChunkFileOutput.getDigest()); + } + + @Test + public void getDigest_returnsCorrectDigest() throws Exception { + processTestChunks(); + + byte[] actualDigest = mDecryptedChunkFileOutput.getDigest(); + + MessageDigest expectedDigest = + MessageDigest.getInstance(DecryptedChunkFileOutput.DIGEST_ALGORITHM); + expectedDigest.update(TEST_CHUNK_1); + expectedDigest.update(TEST_CHUNK_2); + assertThat(actualDigest).isEqualTo(expectedDigest.digest()); + } + + @Test + public void getDigest_whenRunTwice_returnsIdenticalDigestBothTimes() throws Exception { + processTestChunks(); + + byte[] digest1 = mDecryptedChunkFileOutput.getDigest(); + byte[] digest2 = mDecryptedChunkFileOutput.getDigest(); + + assertThat(digest1).isEqualTo(digest2); + } + + private void processTestChunks() throws IOException { + mDecryptedChunkFileOutput.open(); + mDecryptedChunkFileOutput.processChunk(Arrays.copyOf(TEST_CHUNK_1, TEST_BUFFER_LENGTH), + TEST_CHUNK_1.length); + mDecryptedChunkFileOutput.processChunk(Arrays.copyOf(TEST_CHUNK_2, TEST_BUFFER_LENGTH), + TEST_CHUNK_2.length); + mDecryptedChunkFileOutput.close(); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriterTest.java new file mode 100644 index 000000000000..2af6f2bee8ff --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriterTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.platform.test.annotations.Presubmit; + +import com.google.common.primitives.Bytes; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.robolectric.RobolectricTestRunner; + +import java.io.IOException; + +/** Tests for {@link DiffScriptBackupWriter}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class DiffScriptBackupWriterTest { + private static final byte[] TEST_BYTES = {1, 2, 3, 4, 5, 6, 7, 8, 9}; + + @Captor private ArgumentCaptor<Byte> mBytesCaptor; + @Mock private SingleStreamDiffScriptWriter mDiffScriptWriter; + private BackupWriter mBackupWriter; + + @Before + public void setUp() { + mDiffScriptWriter = mock(SingleStreamDiffScriptWriter.class); + mBackupWriter = new DiffScriptBackupWriter(mDiffScriptWriter); + mBytesCaptor = ArgumentCaptor.forClass(Byte.class); + } + + @Test + public void writeBytes_writesBytesToWriter() throws Exception { + mBackupWriter.writeBytes(TEST_BYTES); + + verify(mDiffScriptWriter, atLeastOnce()).writeByte(mBytesCaptor.capture()); + assertThat(mBytesCaptor.getAllValues()) + .containsExactlyElementsIn(Bytes.asList(TEST_BYTES)) + .inOrder(); + } + + @Test + public void writeChunk_writesChunkToWriter() throws Exception { + mBackupWriter.writeChunk(0, 10); + + verify(mDiffScriptWriter).writeChunk(0, 10); + } + + @Test + public void getBytesWritten_returnsTotalSum() throws Exception { + mBackupWriter.writeBytes(TEST_BYTES); + mBackupWriter.writeBytes(TEST_BYTES); + mBackupWriter.writeChunk(/*start=*/ 0, /*length=*/ 10); + + long bytesWritten = mBackupWriter.getBytesWritten(); + + assertThat(bytesWritten).isEqualTo(2 * TEST_BYTES.length + 10); + } + + @Test + public void flush_flushesWriter() throws IOException { + mBackupWriter.flush(); + + verify(mDiffScriptWriter).flush(); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/EncryptedChunkTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/EncryptedChunkTest.java new file mode 100644 index 000000000000..325b601e2ccb --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/EncryptedChunkTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.chunk.ChunkHash; + +import com.google.common.primitives.Bytes; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.Arrays; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class EncryptedChunkTest { + private static final byte[] CHUNK_HASH_1_BYTES = + Arrays.copyOf(new byte[] {1}, ChunkHash.HASH_LENGTH_BYTES); + private static final byte[] NONCE_1 = + Arrays.copyOf(new byte[] {2}, EncryptedChunk.NONCE_LENGTH_BYTES); + private static final byte[] ENCRYPTED_BYTES_1 = + Arrays.copyOf(new byte[] {3}, EncryptedChunk.KEY_LENGTH_BYTES); + + private static final byte[] CHUNK_HASH_2_BYTES = + Arrays.copyOf(new byte[] {4}, ChunkHash.HASH_LENGTH_BYTES); + private static final byte[] NONCE_2 = + Arrays.copyOf(new byte[] {5}, EncryptedChunk.NONCE_LENGTH_BYTES); + private static final byte[] ENCRYPTED_BYTES_2 = + Arrays.copyOf(new byte[] {6}, EncryptedChunk.KEY_LENGTH_BYTES); + + @Test + public void testCreate_withIncorrectLength_throwsException() { + ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES); + byte[] shortNonce = Arrays.copyOf(new byte[] {2}, EncryptedChunk.NONCE_LENGTH_BYTES - 1); + + assertThrows( + IllegalArgumentException.class, + () -> EncryptedChunk.create(chunkHash, shortNonce, ENCRYPTED_BYTES_1)); + } + + @Test + public void testEncryptedBytes_forNewlyCreatedObject_returnsCorrectValue() { + ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES); + EncryptedChunk encryptedChunk = + EncryptedChunk.create(chunkHash, NONCE_1, ENCRYPTED_BYTES_1); + + byte[] returnedBytes = encryptedChunk.encryptedBytes(); + + assertThat(returnedBytes) + .asList() + .containsExactlyElementsIn(Bytes.asList(ENCRYPTED_BYTES_1)) + .inOrder(); + } + + @Test + public void testKey_forNewlyCreatedObject_returnsCorrectValue() { + ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES); + EncryptedChunk encryptedChunk = + EncryptedChunk.create(chunkHash, NONCE_1, ENCRYPTED_BYTES_1); + + ChunkHash returnedKey = encryptedChunk.key(); + + assertThat(returnedKey).isEqualTo(chunkHash); + } + + @Test + public void testNonce_forNewlycreatedObject_returnCorrectValue() { + ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES); + EncryptedChunk encryptedChunk = + EncryptedChunk.create(chunkHash, NONCE_1, ENCRYPTED_BYTES_1); + + byte[] returnedNonce = encryptedChunk.nonce(); + + assertThat(returnedNonce).asList().containsExactlyElementsIn(Bytes.asList(NONCE_1)); + } + + @Test + public void testEquals() { + ChunkHash chunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES); + ChunkHash equalChunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES); + ChunkHash chunkHash2 = new ChunkHash(CHUNK_HASH_2_BYTES); + EncryptedChunk encryptedChunk1 = + EncryptedChunk.create(chunkHash1, NONCE_1, ENCRYPTED_BYTES_1); + EncryptedChunk equalEncryptedChunk1 = + EncryptedChunk.create(equalChunkHash1, NONCE_1, ENCRYPTED_BYTES_1); + EncryptedChunk encryptedChunk2 = + EncryptedChunk.create(chunkHash2, NONCE_2, ENCRYPTED_BYTES_2); + + assertThat(encryptedChunk1).isEqualTo(equalEncryptedChunk1); + assertThat(encryptedChunk1).isNotEqualTo(encryptedChunk2); + } + + @Test + public void testHashCode() { + ChunkHash chunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES); + ChunkHash equalChunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES); + ChunkHash chunkHash2 = new ChunkHash(CHUNK_HASH_2_BYTES); + EncryptedChunk encryptedChunk1 = + EncryptedChunk.create(chunkHash1, NONCE_1, ENCRYPTED_BYTES_1); + EncryptedChunk equalEncryptedChunk1 = + EncryptedChunk.create(equalChunkHash1, NONCE_1, ENCRYPTED_BYTES_1); + EncryptedChunk encryptedChunk2 = + EncryptedChunk.create(chunkHash2, NONCE_2, ENCRYPTED_BYTES_2); + + int hash1 = encryptedChunk1.hashCode(); + int equalHash1 = equalEncryptedChunk1.hashCode(); + int hash2 = encryptedChunk2.hashCode(); + + assertThat(hash1).isEqualTo(equalHash1); + assertThat(hash1).isNotEqualTo(hash2); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoderTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoderTest.java new file mode 100644 index 000000000000..634acdc42eaf --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoderTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.chunk.ChunkHash; +import com.android.server.backup.encryption.chunk.ChunksMetadataProto; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.robolectric.RobolectricTestRunner; + +import java.util.Arrays; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class InlineLengthsEncryptedChunkEncoderTest { + + private static final byte[] TEST_NONCE = + Arrays.copyOf(new byte[] {1}, EncryptedChunk.NONCE_LENGTH_BYTES); + private static final byte[] TEST_KEY_DATA = + Arrays.copyOf(new byte[] {2}, EncryptedChunk.KEY_LENGTH_BYTES); + private static final byte[] TEST_DATA = {5, 4, 5, 7, 10, 12, 1, 2, 9}; + + @Mock private BackupWriter mMockBackupWriter; + private ChunkHash mTestKey; + private EncryptedChunk mTestChunk; + private EncryptedChunkEncoder mEncoder; + + @Before + public void setUp() throws Exception { + mMockBackupWriter = mock(BackupWriter.class); + mTestKey = new ChunkHash(TEST_KEY_DATA); + mTestChunk = EncryptedChunk.create(mTestKey, TEST_NONCE, TEST_DATA); + mEncoder = new InlineLengthsEncryptedChunkEncoder(); + } + + @Test + public void writeChunkToWriter_writesLengthThenNonceThenData() throws Exception { + mEncoder.writeChunkToWriter(mMockBackupWriter, mTestChunk); + + InOrder inOrder = inOrder(mMockBackupWriter); + inOrder.verify(mMockBackupWriter) + .writeBytes( + InlineLengthsEncryptedChunkEncoder.toByteArray( + TEST_NONCE.length + TEST_DATA.length)); + inOrder.verify(mMockBackupWriter).writeBytes(TEST_NONCE); + inOrder.verify(mMockBackupWriter).writeBytes(TEST_DATA); + } + + @Test + public void getEncodedLengthOfChunk_returnsSumOfNonceAndDataLengths() { + int encodedLength = mEncoder.getEncodedLengthOfChunk(mTestChunk); + + assertThat(encodedLength).isEqualTo(Integer.BYTES + TEST_NONCE.length + TEST_DATA.length); + } + + @Test + public void getChunkOrderingType_returnsExplicitStartsType() { + assertThat(mEncoder.getChunkOrderingType()).isEqualTo(ChunksMetadataProto.INLINE_LENGTHS); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoderTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoderTest.java new file mode 100644 index 000000000000..d231603e18b1 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoderTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.chunk.ChunkHash; +import com.android.server.backup.encryption.chunk.ChunksMetadataProto; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.robolectric.RobolectricTestRunner; + +import java.util.Arrays; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class LengthlessEncryptedChunkEncoderTest { + private static final byte[] TEST_NONCE = + Arrays.copyOf(new byte[] {1}, EncryptedChunk.NONCE_LENGTH_BYTES); + private static final byte[] TEST_KEY_DATA = + Arrays.copyOf(new byte[] {2}, EncryptedChunk.KEY_LENGTH_BYTES); + private static final byte[] TEST_DATA = {5, 4, 5, 7, 10, 12, 1, 2, 9}; + + @Mock private BackupWriter mMockBackupWriter; + private ChunkHash mTestKey; + private EncryptedChunk mTestChunk; + private EncryptedChunkEncoder mEncoder; + + @Before + public void setUp() throws Exception { + mMockBackupWriter = mock(BackupWriter.class); + mTestKey = new ChunkHash(TEST_KEY_DATA); + mTestChunk = EncryptedChunk.create(mTestKey, TEST_NONCE, TEST_DATA); + mEncoder = new LengthlessEncryptedChunkEncoder(); + } + + @Test + public void writeChunkToWriter_writesNonceThenData() throws Exception { + mEncoder.writeChunkToWriter(mMockBackupWriter, mTestChunk); + + InOrder inOrder = inOrder(mMockBackupWriter); + inOrder.verify(mMockBackupWriter).writeBytes(TEST_NONCE); + inOrder.verify(mMockBackupWriter).writeBytes(TEST_DATA); + } + + @Test + public void getEncodedLengthOfChunk_returnsSumOfNonceAndDataLengths() { + int encodedLength = mEncoder.getEncodedLengthOfChunk(mTestChunk); + + assertThat(encodedLength).isEqualTo(TEST_NONCE.length + TEST_DATA.length); + } + + @Test + public void getChunkOrderingType_returnsExplicitStartsType() { + assertThat(mEncoder.getChunkOrderingType()).isEqualTo(ChunksMetadataProto.EXPLICIT_STARTS); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/RawBackupWriterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/RawBackupWriterTest.java new file mode 100644 index 000000000000..966d3e2d583d --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/RawBackupWriterTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import com.google.common.primitives.Bytes; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayOutputStream; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class RawBackupWriterTest { + private static final byte[] TEST_BYTES = {1, 2, 3, 4, 5, 6}; + + private BackupWriter mWriter; + private ByteArrayOutputStream mOutput; + + @Before + public void setUp() { + mOutput = new ByteArrayOutputStream(); + mWriter = new RawBackupWriter(mOutput); + } + + @Test + public void writeBytes_writesToOutputStream() throws Exception { + mWriter.writeBytes(TEST_BYTES); + + assertThat(mOutput.toByteArray()) + .asList() + .containsExactlyElementsIn(Bytes.asList(TEST_BYTES)) + .inOrder(); + } + + @Test + public void writeChunk_throwsUnsupportedOperationException() throws Exception { + assertThrows(UnsupportedOperationException.class, () -> mWriter.writeChunk(0, 0)); + } + + @Test + public void getBytesWritten_returnsTotalSum() throws Exception { + mWriter.writeBytes(TEST_BYTES); + mWriter.writeBytes(TEST_BYTES); + + long bytesWritten = mWriter.getBytesWritten(); + + assertThat(bytesWritten).isEqualTo(2 * TEST_BYTES.length); + } + + @Test + public void flush_flushesOutputStream() throws Exception { + mOutput = mock(ByteArrayOutputStream.class); + mWriter = new RawBackupWriter(mOutput); + + mWriter.flush(); + + verify(mOutput).flush(); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriterTest.java new file mode 100644 index 000000000000..73baf80a2c70 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriterTest.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2018 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.chunking; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Locale; + +/** Tests for {@link SingleStreamDiffScriptWriter}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class SingleStreamDiffScriptWriterTest { + private static final int MAX_CHUNK_SIZE_IN_BYTES = 256; + /** By default this Locale does not use Arabic numbers for %d formatting. */ + private static final Locale HINDI = new Locale("hi", "IN"); + + private Locale mDefaultLocale; + private ByteArrayOutputStream mOutputStream; + private SingleStreamDiffScriptWriter mDiffScriptWriter; + + @Before + public void setUp() { + mDefaultLocale = Locale.getDefault(); + mOutputStream = new ByteArrayOutputStream(); + mDiffScriptWriter = + new SingleStreamDiffScriptWriter(mOutputStream, MAX_CHUNK_SIZE_IN_BYTES); + } + + @After + public void tearDown() { + Locale.setDefault(mDefaultLocale); + } + + @Test + public void writeChunk_withNegativeStart_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> mDiffScriptWriter.writeChunk(-1, 50)); + } + + @Test + public void writeChunk_withZeroLength_throwsException() { + assertThrows( + IllegalArgumentException.class, + () -> mDiffScriptWriter.writeChunk(0, 0)); + } + + @Test + public void writeChunk_withExistingBytesInBuffer_writesBufferFirst() + throws IOException { + String testString = "abcd"; + writeStringAsBytesToWriter(testString, mDiffScriptWriter); + + mDiffScriptWriter.writeChunk(0, 20); + mDiffScriptWriter.flush(); + + // Expected format: length of abcd, newline, abcd, newline, chunk start - chunk end + assertThat(mOutputStream.toString("UTF-8")).isEqualTo( + String.format("%d\n%s\n%d-%d\n", testString.length(), testString, 0, 19)); + } + + @Test + public void writeChunk_overlappingPreviousChunk_combinesChunks() throws IOException { + mDiffScriptWriter.writeChunk(3, 4); + + mDiffScriptWriter.writeChunk(7, 5); + mDiffScriptWriter.flush(); + + assertThat(mOutputStream.toString("UTF-8")).isEqualTo(String.format("3-11\n")); + } + + @Test + public void writeChunk_formatsByteIndexesUsingArabicNumbers() throws Exception { + Locale.setDefault(HINDI); + + mDiffScriptWriter.writeChunk(0, 12345); + mDiffScriptWriter.flush(); + + assertThat(mOutputStream.toString("UTF-8")).isEqualTo("0-12344\n"); + } + + @Test + public void flush_flushesOutputStream() throws IOException { + ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); + SingleStreamDiffScriptWriter diffScriptWriter = + new SingleStreamDiffScriptWriter(mockOutputStream, MAX_CHUNK_SIZE_IN_BYTES); + + diffScriptWriter.flush(); + + verify(mockOutputStream).flush(); + } + + private void writeStringAsBytesToWriter(String string, SingleStreamDiffScriptWriter writer) + throws IOException { + byte[] bytes = string.getBytes("UTF-8"); + for (int i = 0; i < bytes.length; i++) { + writer.writeByte(bytes[i]); + } + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunkerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunkerTest.java new file mode 100644 index 000000000000..77b734785424 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunkerTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Random; + +import javax.crypto.SecretKey; + +/** Tests for {@link ContentDefinedChunker}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class ContentDefinedChunkerTest { + private static final int WINDOW_SIZE_BYTES = 31; + private static final int MIN_SIZE_BYTES = 40; + private static final int MAX_SIZE_BYTES = 300; + private static final String CHUNK_BOUNDARY = "<----------BOUNDARY----------->"; + private static final byte[] CHUNK_BOUNDARY_BYTES = CHUNK_BOUNDARY.getBytes(UTF_8); + private static final String CHUNK_1 = "This is the first chunk"; + private static final String CHUNK_2 = "And this is the second chunk"; + private static final String CHUNK_3 = "And finally here is the third chunk"; + private static final String SMALL_CHUNK = "12345678"; + + private FingerprintMixer mFingerprintMixer; + private RabinFingerprint64 mRabinFingerprint64; + private ContentDefinedChunker mChunker; + + /** Set up a {@link ContentDefinedChunker} and dependencies for use in the tests. */ + @Before + public void setUp() throws Exception { + SecretKey secretKey = generateAesKey(); + byte[] salt = new byte[FingerprintMixer.SALT_LENGTH_BYTES]; + Random random = new Random(); + random.nextBytes(salt); + mFingerprintMixer = new FingerprintMixer(secretKey, salt); + + mRabinFingerprint64 = new RabinFingerprint64(); + long chunkBoundaryFingerprint = calculateFingerprint(CHUNK_BOUNDARY_BYTES); + mChunker = + new ContentDefinedChunker( + MIN_SIZE_BYTES, + MAX_SIZE_BYTES, + mRabinFingerprint64, + mFingerprintMixer, + (fingerprint) -> fingerprint == chunkBoundaryFingerprint); + } + + /** + * Creating a {@link ContentDefinedChunker} with a minimum chunk size that is smaller than the + * window size should throw an {@link IllegalArgumentException}. + */ + @Test + public void create_withMinChunkSizeSmallerThanWindowSize_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> + new ContentDefinedChunker( + WINDOW_SIZE_BYTES - 1, + MAX_SIZE_BYTES, + mRabinFingerprint64, + mFingerprintMixer, + null)); + } + + /** + * Creating a {@link ContentDefinedChunker} with a maximum chunk size that is smaller than the + * minimum chunk size should throw an {@link IllegalArgumentException}. + */ + @Test + public void create_withMaxChunkSizeSmallerThanMinChunkSize_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> + new ContentDefinedChunker( + MIN_SIZE_BYTES, + MIN_SIZE_BYTES - 1, + mRabinFingerprint64, + mFingerprintMixer, + null)); + } + + /** + * {@link ContentDefinedChunker#chunkify(InputStream, Chunker.ChunkConsumer)} should split the + * input stream across chunk boundaries by default. + */ + @Test + public void chunkify_withLargeChunks_splitsIntoChunksAcrossBoundaries() throws Exception { + byte[] input = + (CHUNK_1 + CHUNK_BOUNDARY + CHUNK_2 + CHUNK_BOUNDARY + CHUNK_3).getBytes(UTF_8); + ByteArrayInputStream inputStream = new ByteArrayInputStream(input); + ArrayList<String> result = new ArrayList<>(); + + mChunker.chunkify(inputStream, (chunk) -> result.add(new String(chunk, UTF_8))); + + assertThat(result) + .containsExactly(CHUNK_1 + CHUNK_BOUNDARY, CHUNK_2 + CHUNK_BOUNDARY, CHUNK_3) + .inOrder(); + } + + /** Chunks should be combined across boundaries until they reach the minimum chunk size. */ + @Test + public void chunkify_withSmallChunks_combinesChunksUntilMinSize() throws Exception { + byte[] input = + (SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2 + CHUNK_BOUNDARY + CHUNK_3).getBytes(UTF_8); + ByteArrayInputStream inputStream = new ByteArrayInputStream(input); + ArrayList<String> result = new ArrayList<>(); + + mChunker.chunkify(inputStream, (chunk) -> result.add(new String(chunk, UTF_8))); + + assertThat(result) + .containsExactly(SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2 + CHUNK_BOUNDARY, CHUNK_3) + .inOrder(); + assertThat(result.get(0).length()).isAtLeast(MIN_SIZE_BYTES); + } + + /** Chunks can not be larger than the maximum chunk size. */ + @Test + public void chunkify_doesNotProduceChunksLargerThanMaxSize() throws Exception { + byte[] largeInput = new byte[MAX_SIZE_BYTES * 10]; + Arrays.fill(largeInput, "a".getBytes(UTF_8)[0]); + ByteArrayInputStream inputStream = new ByteArrayInputStream(largeInput); + ArrayList<String> result = new ArrayList<>(); + + mChunker.chunkify(inputStream, (chunk) -> result.add(new String(chunk, UTF_8))); + + byte[] expectedChunkBytes = new byte[MAX_SIZE_BYTES]; + Arrays.fill(expectedChunkBytes, "a".getBytes(UTF_8)[0]); + String expectedChunk = new String(expectedChunkBytes, UTF_8); + assertThat(result) + .containsExactly( + expectedChunk, + expectedChunk, + expectedChunk, + expectedChunk, + expectedChunk, + expectedChunk, + expectedChunk, + expectedChunk, + expectedChunk, + expectedChunk) + .inOrder(); + } + + /** + * If the input stream signals zero availablility, {@link + * ContentDefinedChunker#chunkify(InputStream, Chunker.ChunkConsumer)} should still work. + */ + @Test + public void chunkify_withInputStreamReturningZeroAvailability_returnsChunks() throws Exception { + byte[] input = (SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2).getBytes(UTF_8); + ZeroAvailabilityInputStream zeroAvailabilityInputStream = + new ZeroAvailabilityInputStream(input); + ArrayList<String> result = new ArrayList<>(); + + mChunker.chunkify( + zeroAvailabilityInputStream, (chunk) -> result.add(new String(chunk, UTF_8))); + + assertThat(result).containsExactly(SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2).inOrder(); + } + + /** + * {@link ContentDefinedChunker#chunkify(InputStream, Chunker.ChunkConsumer)} should rethrow any + * exception thrown by its consumer. + */ + @Test + public void chunkify_whenConsumerThrowsException_rethrowsException() throws Exception { + ByteArrayInputStream inputStream = new ByteArrayInputStream(new byte[] {1}); + + assertThrows( + GeneralSecurityException.class, + () -> + mChunker.chunkify( + inputStream, + (chunk) -> { + throw new GeneralSecurityException(); + })); + } + + private long calculateFingerprint(byte[] bytes) { + long fingerprint = 0; + for (byte inByte : bytes) { + fingerprint = + mRabinFingerprint64.computeFingerprint64( + /*inChar=*/ inByte, /*outChar=*/ (byte) 0, fingerprint); + } + return mFingerprintMixer.mix(fingerprint); + } + + private static class ZeroAvailabilityInputStream extends ByteArrayInputStream { + ZeroAvailabilityInputStream(byte[] wrapped) { + super(wrapped); + } + + @Override + public synchronized int available() { + return 0; + } + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixerTest.java new file mode 100644 index 000000000000..936b5dca033d --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixerTest.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.security.InvalidKeyException; +import java.security.Key; +import java.util.HashSet; +import java.util.Random; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +/** Tests for {@link FingerprintMixer}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class FingerprintMixerTest { + private static final String KEY_ALGORITHM = "AES"; + private static final int SEED = 42; + private static final int SALT_LENGTH_BYTES = 256 / 8; + private static final int KEY_SIZE_BITS = 256; + + private Random mSeededRandom; + private FingerprintMixer mFingerprintMixer; + + /** Set up a {@link FingerprintMixer} with deterministic key and salt generation. */ + @Before + public void setUp() throws Exception { + // Seed so that the tests are deterministic. + mSeededRandom = new Random(SEED); + mFingerprintMixer = new FingerprintMixer(randomKey(), randomSalt()); + } + + /** + * Construcing a {@link FingerprintMixer} with a salt that is too small should throw an {@link + * IllegalArgumentException}. + */ + @Test + public void create_withIncorrectSaltSize_throwsIllegalArgumentException() { + byte[] tooSmallSalt = new byte[SALT_LENGTH_BYTES - 1]; + + assertThrows( + IllegalArgumentException.class, + () -> new FingerprintMixer(randomKey(), tooSmallSalt)); + } + + /** + * Constructing a {@link FingerprintMixer} with a secret key that can't be encoded should throw + * an {@link InvalidKeyException}. + */ + @Test + public void create_withUnencodableSecretKey_throwsInvalidKeyException() { + byte[] keyBytes = new byte[KEY_SIZE_BITS / 8]; + UnencodableSecretKeySpec keySpec = + new UnencodableSecretKeySpec(keyBytes, 0, keyBytes.length, KEY_ALGORITHM); + + assertThrows(InvalidKeyException.class, () -> new FingerprintMixer(keySpec, randomSalt())); + } + + /** + * {@link FingerprintMixer#getAddend()} should not return the same addend for two different + * keys. + */ + @Test + public void getAddend_withDifferentKey_returnsDifferentResult() throws Exception { + int iterations = 100_000; + HashSet<Long> returnedAddends = new HashSet<>(); + byte[] salt = randomSalt(); + + for (int i = 0; i < iterations; i++) { + FingerprintMixer fingerprintMixer = new FingerprintMixer(randomKey(), salt); + long addend = fingerprintMixer.getAddend(); + returnedAddends.add(addend); + } + + assertThat(returnedAddends).containsNoDuplicates(); + } + + /** + * {@link FingerprintMixer#getMultiplicand()} should not return the same multiplicand for two + * different keys. + */ + @Test + public void getMultiplicand_withDifferentKey_returnsDifferentResult() throws Exception { + int iterations = 100_000; + HashSet<Long> returnedMultiplicands = new HashSet<>(); + byte[] salt = randomSalt(); + + for (int i = 0; i < iterations; i++) { + FingerprintMixer fingerprintMixer = new FingerprintMixer(randomKey(), salt); + long multiplicand = fingerprintMixer.getMultiplicand(); + returnedMultiplicands.add(multiplicand); + } + + assertThat(returnedMultiplicands).containsNoDuplicates(); + } + + /** The multiplicant returned by {@link FingerprintMixer} should always be odd. */ + @Test + public void getMultiplicand_isOdd() throws Exception { + int iterations = 100_000; + + for (int i = 0; i < iterations; i++) { + FingerprintMixer fingerprintMixer = new FingerprintMixer(randomKey(), randomSalt()); + + long multiplicand = fingerprintMixer.getMultiplicand(); + + assertThat(isOdd(multiplicand)).isTrue(); + } + } + + /** {@link FingerprintMixer#mix(long)} should have a random distribution. */ + @Test + public void mix_randomlyDistributesBits() throws Exception { + int iterations = 100_000; + float tolerance = 0.1f; + int[] totals = new int[64]; + + for (int i = 0; i < iterations; i++) { + long n = mFingerprintMixer.mix(mSeededRandom.nextLong()); + for (int j = 0; j < 64; j++) { + int bit = (int) (n >> j & 1); + totals[j] += bit; + } + } + + for (int i = 0; i < 64; i++) { + float mean = ((float) totals[i]) / iterations; + float diff = Math.abs(mean - 0.5f); + assertThat(diff).isLessThan(tolerance); + } + } + + /** + * {@link FingerprintMixer#mix(long)} should always produce a number that's different from the + * input. + */ + @Test + public void mix_doesNotProduceSameNumberAsInput() { + int iterations = 100_000; + + for (int i = 0; i < iterations; i++) { + assertThat(mFingerprintMixer.mix(i)).isNotEqualTo(i); + } + } + + private byte[] randomSalt() { + byte[] salt = new byte[SALT_LENGTH_BYTES]; + mSeededRandom.nextBytes(salt); + return salt; + } + + /** + * Not a secure way of generating keys. We want to deterministically generate the same keys for + * each test run, though, to ensure the test is deterministic. + */ + private SecretKey randomKey() { + byte[] keyBytes = new byte[KEY_SIZE_BITS / 8]; + mSeededRandom.nextBytes(keyBytes); + return new SecretKeySpec(keyBytes, 0, keyBytes.length, KEY_ALGORITHM); + } + + private static boolean isOdd(long n) { + return Math.abs(n % 2) == 1; + } + + /** + * Subclass of {@link SecretKeySpec} that does not provide an encoded version. As per its + * contract in {@link Key}, that means {@code getEncoded()} always returns null. + */ + private class UnencodableSecretKeySpec extends SecretKeySpec { + UnencodableSecretKeySpec(byte[] key, int offset, int len, String algorithm) { + super(key, offset, len, algorithm); + } + + @Override + public byte[] getEncoded() { + return null; + } + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/HkdfTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/HkdfTest.java new file mode 100644 index 000000000000..549437454e9c --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/HkdfTest.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link Hkdf}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class HkdfTest { + /** HKDF Test Case 1 IKM from RFC 5869 */ + private static final byte[] HKDF_CASE1_IKM = { + 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, + 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, + 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, + 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, + 0x0b, 0x0b + }; + + /** HKDF Test Case 1 salt from RFC 5869 */ + private static final byte[] HKDF_CASE1_SALT = { + 0x00, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, + 0x0a, 0x0b, 0x0c + }; + + /** HKDF Test Case 1 info from RFC 5869 */ + private static final byte[] HKDF_CASE1_INFO = { + (byte) 0xf0, (byte) 0xf1, (byte) 0xf2, (byte) 0xf3, (byte) 0xf4, + (byte) 0xf5, (byte) 0xf6, (byte) 0xf7, (byte) 0xf8, (byte) 0xf9 + }; + + /** First 32 bytes of HKDF Test Case 1 OKM (output) from RFC 5869 */ + private static final byte[] HKDF_CASE1_OKM = { + (byte) 0x3c, (byte) 0xb2, (byte) 0x5f, (byte) 0x25, (byte) 0xfa, + (byte) 0xac, (byte) 0xd5, (byte) 0x7a, (byte) 0x90, (byte) 0x43, + (byte) 0x4f, (byte) 0x64, (byte) 0xd0, (byte) 0x36, (byte) 0x2f, + (byte) 0x2a, (byte) 0x2d, (byte) 0x2d, (byte) 0x0a, (byte) 0x90, + (byte) 0xcf, (byte) 0x1a, (byte) 0x5a, (byte) 0x4c, (byte) 0x5d, + (byte) 0xb0, (byte) 0x2d, (byte) 0x56, (byte) 0xec, (byte) 0xc4, + (byte) 0xc5, (byte) 0xbf + }; + + /** Test the example from RFC 5869. */ + @Test + public void hkdf_derivesKeyMaterial() throws Exception { + byte[] result = Hkdf.hkdf(HKDF_CASE1_IKM, HKDF_CASE1_SALT, HKDF_CASE1_INFO); + + assertThat(result).isEqualTo(HKDF_CASE1_OKM); + } + + /** Providing a key that is null should throw a {@link java.lang.NullPointerException}. */ + @Test + public void hkdf_withNullKey_throwsNullPointerException() throws Exception { + assertThrows( + NullPointerException.class, + () -> Hkdf.hkdf(null, HKDF_CASE1_SALT, HKDF_CASE1_INFO)); + } + + /** Providing a salt that is null should throw a {@link java.lang.NullPointerException}. */ + @Test + public void hkdf_withNullSalt_throwsNullPointerException() throws Exception { + assertThrows( + NullPointerException.class, () -> Hkdf.hkdf(HKDF_CASE1_IKM, null, HKDF_CASE1_INFO)); + } + + /** Providing data that is null should throw a {@link java.lang.NullPointerException}. */ + @Test + public void hkdf_withNullData_throwsNullPointerException() throws Exception { + assertThrows( + NullPointerException.class, () -> Hkdf.hkdf(HKDF_CASE1_IKM, HKDF_CASE1_SALT, null)); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpointTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpointTest.java new file mode 100644 index 000000000000..277dc372e73c --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpointTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.Random; + +/** Tests for {@link IsChunkBreakpoint}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class IsChunkBreakpointTest { + private static final int RANDOM_SEED = 42; + private static final double TOLERANCE = 0.01; + private static final int NUMBER_OF_TESTS = 10000; + private static final int BITS_PER_LONG = 64; + + private Random mRandom; + + /** Make sure that tests are deterministic. */ + @Before + public void setUp() { + mRandom = new Random(RANDOM_SEED); + } + + /** + * Providing a negative average number of trials should throw an {@link + * IllegalArgumentException}. + */ + @Test + public void create_withNegativeAverageNumberOfTrials_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> new IsChunkBreakpoint(-1)); + } + + // Note: the following three tests are compute-intensive, so be cautious adding more. + + /** + * If the provided average number of trials is zero, a breakpoint should be expected after one + * trial on average. + */ + @Test + public void + isBreakpoint_withZeroAverageNumberOfTrials_isTrueOnAverageAfterOneTrial() { + assertExpectedTrials(new IsChunkBreakpoint(0), /*expectedTrials=*/ 1); + } + + /** + * If the provided average number of trials is 512, a breakpoint should be expected after 512 + * trials on average. + */ + @Test + public void + isBreakpoint_with512AverageNumberOfTrials_isTrueOnAverageAfter512Trials() { + assertExpectedTrials(new IsChunkBreakpoint(512), /*expectedTrials=*/ 512); + } + + /** + * If the provided average number of trials is 1024, a breakpoint should be expected after 1024 + * trials on average. + */ + @Test + public void + isBreakpoint_with1024AverageNumberOfTrials_isTrueOnAverageAfter1024Trials() { + assertExpectedTrials(new IsChunkBreakpoint(1024), /*expectedTrials=*/ 1024); + } + + /** The number of leading zeros should be the logarithm of the average number of trials. */ + @Test + public void getLeadingZeros_squaredIsAverageNumberOfTrials() { + for (int i = 0; i < BITS_PER_LONG; i++) { + long averageNumberOfTrials = (long) Math.pow(2, i); + + int leadingZeros = new IsChunkBreakpoint(averageNumberOfTrials).getLeadingZeros(); + + assertThat(leadingZeros).isEqualTo(i); + } + } + + private void assertExpectedTrials(IsChunkBreakpoint isChunkBreakpoint, long expectedTrials) { + long sum = 0; + for (int i = 0; i < NUMBER_OF_TESTS; i++) { + sum += numberOfTrialsTillBreakpoint(isChunkBreakpoint); + } + long averageTrials = sum / NUMBER_OF_TESTS; + assertThat((double) Math.abs(averageTrials - expectedTrials)) + .isLessThan(TOLERANCE * expectedTrials); + } + + private int numberOfTrialsTillBreakpoint(IsChunkBreakpoint isChunkBreakpoint) { + int trials = 0; + + while (true) { + trials++; + if (isChunkBreakpoint.isBreakpoint(mRandom.nextLong())) { + return trials; + } + } + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64Test.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64Test.java new file mode 100644 index 000000000000..729580cf5101 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64Test.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2018 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.chunking.cdc; + +import static com.google.common.truth.Truth.assertThat; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.platform.test.annotations.Presubmit; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link RabinFingerprint64}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class RabinFingerprint64Test { + private static final int WINDOW_SIZE = 31; + private static final ImmutableList<String> TEST_STRINGS = + ImmutableList.of( + "ervHTtChYXO6eXivYqThlyyzqkbRaOR", + "IxaVunH9ZC3qneWfhj1GkBH4ys9CYqz", + "wZRVjlE1p976icCFPX9pibk4PEBvjSH", + "pHIVaT8x8If9D6s9croksgNmJpmGYWI"); + + private final RabinFingerprint64 mRabinFingerprint64 = new RabinFingerprint64(); + + /** + * No matter where in the input buffer a string occurs, {@link + * RabinFingerprint64#computeFingerprint64(byte, byte, long)} should return the same + * fingerprint. + */ + @Test + public void computeFingerprint64_forSameWindow_returnsSameFingerprint() { + long fingerprint1 = + computeFingerprintAtPosition(getBytes(TEST_STRINGS.get(0)), WINDOW_SIZE - 1); + long fingerprint2 = + computeFingerprintAtPosition( + getBytes(TEST_STRINGS.get(1), TEST_STRINGS.get(0)), WINDOW_SIZE * 2 - 1); + long fingerprint3 = + computeFingerprintAtPosition( + getBytes(TEST_STRINGS.get(2), TEST_STRINGS.get(3), TEST_STRINGS.get(0)), + WINDOW_SIZE * 3 - 1); + String stub = "abc"; + long fingerprint4 = + computeFingerprintAtPosition( + getBytes(stub, TEST_STRINGS.get(0)), WINDOW_SIZE + stub.length() - 1); + + // Assert that all fingerprints are exactly the same + assertThat(ImmutableSet.of(fingerprint1, fingerprint2, fingerprint3, fingerprint4)) + .hasSize(1); + } + + /** The computed fingerprint should be different for different inputs. */ + @Test + public void computeFingerprint64_withDifferentInput_returnsDifferentFingerprint() { + long fingerprint1 = computeFingerprintOf(TEST_STRINGS.get(0)); + long fingerprint2 = computeFingerprintOf(TEST_STRINGS.get(1)); + long fingerprint3 = computeFingerprintOf(TEST_STRINGS.get(2)); + long fingerprint4 = computeFingerprintOf(TEST_STRINGS.get(3)); + + assertThat(ImmutableList.of(fingerprint1, fingerprint2, fingerprint3, fingerprint4)) + .containsNoDuplicates(); + } + + /** + * An input with the same characters in a different order should return a different fingerprint. + */ + @Test + public void computeFingerprint64_withSameInputInDifferentOrder_returnsDifferentFingerprint() { + long fingerprint1 = computeFingerprintOf("abcdefghijklmnopqrstuvwxyz12345"); + long fingerprint2 = computeFingerprintOf("54321zyxwvutsrqponmlkjihgfedcba"); + long fingerprint3 = computeFingerprintOf("4bcdefghijklmnopqrstuvwxyz123a5"); + long fingerprint4 = computeFingerprintOf("bacdefghijklmnopqrstuvwxyz12345"); + + assertThat(ImmutableList.of(fingerprint1, fingerprint2, fingerprint3, fingerprint4)) + .containsNoDuplicates(); + } + + /** UTF-8 bytes of all the given strings in order. */ + private byte[] getBytes(String... strings) { + StringBuilder sb = new StringBuilder(); + for (String s : strings) { + sb.append(s); + } + return sb.toString().getBytes(UTF_8); + } + + /** + * The Rabin fingerprint of a window of bytes ending at {@code position} in the {@code bytes} + * array. + */ + private long computeFingerprintAtPosition(byte[] bytes, int position) { + assertThat(position).isAtMost(bytes.length - 1); + long fingerprint = 0; + for (int i = 0; i <= position; i++) { + byte outChar; + if (i >= WINDOW_SIZE) { + outChar = bytes[i - WINDOW_SIZE]; + } else { + outChar = (byte) 0; + } + fingerprint = + mRabinFingerprint64.computeFingerprint64( + /*inChar=*/ bytes[i], outChar, fingerprint); + } + return fingerprint; + } + + private long computeFingerprintOf(String s) { + assertThat(s.length()).isEqualTo(WINDOW_SIZE); + return computeFingerprintAtPosition(s.getBytes(UTF_8), WINDOW_SIZE - 1); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/KeyWrapUtilsTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/KeyWrapUtilsTest.java new file mode 100644 index 000000000000..b60740421ad5 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/KeyWrapUtilsTest.java @@ -0,0 +1,158 @@ +/* + * 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.keys; + +import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.security.InvalidKeyException; + +import javax.crypto.SecretKey; + +/** Key wrapping tests */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class KeyWrapUtilsTest { + private static final int KEY_SIZE_BITS = 256; + private static final int BITS_PER_BYTE = 8; + private static final int GCM_NONCE_LENGTH_BYTES = 16; + private static final int GCM_TAG_LENGTH_BYTES = 16; + + /** Test a wrapped key has metadata */ + @Test + public void wrap_addsMetadata() throws Exception { + WrappedKeyProto.WrappedKey wrappedKey = + KeyWrapUtils.wrap( + /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey()); + assertThat(wrappedKey.metadata).isNotNull(); + assertThat(wrappedKey.metadata.type).isEqualTo(WrappedKeyProto.KeyMetadata.AES_256_GCM); + } + + /** Test a wrapped key has an algorithm specified */ + @Test + public void wrap_addsWrapAlgorithm() throws Exception { + WrappedKeyProto.WrappedKey wrappedKey = + KeyWrapUtils.wrap( + /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey()); + assertThat(wrappedKey.wrapAlgorithm).isEqualTo(WrappedKeyProto.WrappedKey.AES_256_GCM); + } + + /** Test a wrapped key haas an nonce of the right length */ + @Test + public void wrap_addsNonceOfAppropriateLength() throws Exception { + WrappedKeyProto.WrappedKey wrappedKey = + KeyWrapUtils.wrap( + /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey()); + assertThat(wrappedKey.nonce).hasLength(GCM_NONCE_LENGTH_BYTES); + } + + /** Test a wrapped key has a key of the right length */ + @Test + public void wrap_addsTagOfAppropriateLength() throws Exception { + WrappedKeyProto.WrappedKey wrappedKey = + KeyWrapUtils.wrap( + /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey()); + assertThat(wrappedKey.key).hasLength(KEY_SIZE_BITS / BITS_PER_BYTE + GCM_TAG_LENGTH_BYTES); + } + + /** Ensure a key can be wrapped and unwrapped again */ + @Test + public void unwrap_unwrapsEncryptedKey() throws Exception { + SecretKey secondaryKey = generateAesKey(); + SecretKey tertiaryKey = generateAesKey(); + WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, tertiaryKey); + SecretKey unwrappedKey = KeyWrapUtils.unwrap(secondaryKey, wrappedKey); + assertThat(unwrappedKey).isEqualTo(tertiaryKey); + } + + /** Ensure the unwrap method rejects keys with bad algorithms */ + @Test(expected = InvalidKeyException.class) + public void unwrap_throwsForBadWrapAlgorithm() throws Exception { + SecretKey secondaryKey = generateAesKey(); + WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, generateAesKey()); + wrappedKey.wrapAlgorithm = WrappedKeyProto.WrappedKey.UNKNOWN; + + KeyWrapUtils.unwrap(secondaryKey, wrappedKey); + } + + /** Ensure the unwrap method rejects metadata indicating the encryption type is unknown */ + @Test(expected = InvalidKeyException.class) + public void unwrap_throwsForBadKeyAlgorithm() throws Exception { + SecretKey secondaryKey = generateAesKey(); + WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, generateAesKey()); + wrappedKey.metadata.type = WrappedKeyProto.KeyMetadata.UNKNOWN; + + KeyWrapUtils.unwrap(secondaryKey, wrappedKey); + } + + /** Ensure the unwrap method rejects wrapped keys missing the metadata */ + @Test(expected = InvalidKeyException.class) + public void unwrap_throwsForMissingMetadata() throws Exception { + SecretKey secondaryKey = generateAesKey(); + WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(secondaryKey, generateAesKey()); + wrappedKey.metadata = null; + + KeyWrapUtils.unwrap(secondaryKey, wrappedKey); + } + + /** Ensure unwrap rejects invalid secondary keys */ + @Test(expected = InvalidKeyException.class) + public void unwrap_throwsForBadSecondaryKey() throws Exception { + WrappedKeyProto.WrappedKey wrappedKey = + KeyWrapUtils.wrap( + /*secondaryKey=*/ generateAesKey(), /*tertiaryKey=*/ generateAesKey()); + + KeyWrapUtils.unwrap(generateAesKey(), wrappedKey); + } + + /** Ensure rewrap can rewrap keys */ + @Test + public void rewrap_canBeUnwrappedWithNewSecondaryKey() throws Exception { + SecretKey tertiaryKey = generateAesKey(); + SecretKey oldSecondaryKey = generateAesKey(); + SecretKey newSecondaryKey = generateAesKey(); + WrappedKeyProto.WrappedKey wrappedWithOld = KeyWrapUtils.wrap(oldSecondaryKey, tertiaryKey); + + WrappedKeyProto.WrappedKey wrappedWithNew = + KeyWrapUtils.rewrap(oldSecondaryKey, newSecondaryKey, wrappedWithOld); + + assertThat(KeyWrapUtils.unwrap(newSecondaryKey, wrappedWithNew)).isEqualTo(tertiaryKey); + } + + /** Ensure rewrap doesn't create something decryptable by an old key */ + @Test(expected = InvalidKeyException.class) + public void rewrap_cannotBeUnwrappedWithOldSecondaryKey() throws Exception { + SecretKey tertiaryKey = generateAesKey(); + SecretKey oldSecondaryKey = generateAesKey(); + SecretKey newSecondaryKey = generateAesKey(); + WrappedKeyProto.WrappedKey wrappedWithOld = KeyWrapUtils.wrap(oldSecondaryKey, tertiaryKey); + + WrappedKeyProto.WrappedKey wrappedWithNew = + KeyWrapUtils.rewrap(oldSecondaryKey, newSecondaryKey, wrappedWithOld); + + KeyWrapUtils.unwrap(oldSecondaryKey, wrappedWithNew); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManagerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManagerTest.java new file mode 100644 index 000000000000..5342efa18a97 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManagerTest.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2018 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.keys; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.content.Context; +import android.platform.test.annotations.Presubmit; +import android.security.keystore.recovery.InternalRecoveryServiceException; +import android.security.keystore.recovery.RecoveryController; + +import com.android.server.testing.shadows.ShadowInternalRecoveryServiceException; +import com.android.server.testing.shadows.ShadowRecoveryController; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.security.SecureRandom; +import java.util.Optional; + +/** Tests for {@link RecoverableKeyStoreSecondaryKeyManager}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +@Config(shadows = {ShadowRecoveryController.class, ShadowInternalRecoveryServiceException.class}) +public class RecoverableKeyStoreSecondaryKeyManagerTest { + private static final String BACKUP_KEY_ALIAS_PREFIX = + "com.android.server.backup/recoverablekeystore/"; + private static final int BITS_PER_BYTE = 8; + private static final int BACKUP_KEY_SUFFIX_LENGTH_BYTES = 128 / BITS_PER_BYTE; + private static final int HEX_PER_BYTE = 2; + private static final int BACKUP_KEY_ALIAS_LENGTH = + BACKUP_KEY_ALIAS_PREFIX.length() + BACKUP_KEY_SUFFIX_LENGTH_BYTES * HEX_PER_BYTE; + private static final String NONEXISTENT_KEY_ALIAS = "NONEXISTENT_KEY_ALIAS"; + + private RecoverableKeyStoreSecondaryKeyManager mRecoverableKeyStoreSecondaryKeyManager; + private Context mContext; + + /** Create a new {@link RecoverableKeyStoreSecondaryKeyManager} to use in tests. */ + @Before + public void setUp() throws Exception { + mContext = RuntimeEnvironment.application; + + mRecoverableKeyStoreSecondaryKeyManager = + new RecoverableKeyStoreSecondaryKeyManager( + RecoveryController.getInstance(mContext), new SecureRandom()); + } + + /** Reset the {@link ShadowRecoveryController}. */ + @After + public void tearDown() throws Exception { + ShadowRecoveryController.reset(); + } + + /** The generated key should always have the prefix {@code BACKUP_KEY_ALIAS_PREFIX}. */ + @Test + public void generate_generatesKeyWithExpectedPrefix() throws Exception { + RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate(); + + assertThat(key.getAlias()).startsWith(BACKUP_KEY_ALIAS_PREFIX); + } + + /** The generated key should always have length {@code BACKUP_KEY_ALIAS_LENGTH}. */ + @Test + public void generate_generatesKeyWithExpectedLength() throws Exception { + RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate(); + + assertThat(key.getAlias()).hasLength(BACKUP_KEY_ALIAS_LENGTH); + } + + /** Ensure that hidden API exceptions are rethrown when generating keys. */ + @Test + public void generate_encounteringHiddenApiException_rethrowsException() { + ShadowRecoveryController.setThrowsInternalError(true); + + assertThrows( + InternalRecoveryServiceException.class, + mRecoverableKeyStoreSecondaryKeyManager::generate); + } + + /** Ensure that retrieved keys correspond to those generated earlier. */ + @Test + public void get_getsKeyGeneratedByController() throws Exception { + RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate(); + + Optional<RecoverableKeyStoreSecondaryKey> retrievedKey = + mRecoverableKeyStoreSecondaryKeyManager.get(key.getAlias()); + + assertThat(retrievedKey.isPresent()).isTrue(); + assertThat(retrievedKey.get().getAlias()).isEqualTo(key.getAlias()); + assertThat(retrievedKey.get().getSecretKey()).isEqualTo(key.getSecretKey()); + } + + /** + * Ensure that a call to {@link RecoverableKeyStoreSecondaryKeyManager#get(java.lang.String)} + * for nonexistent aliases returns an emtpy {@link Optional}. + */ + @Test + public void get_forNonExistentKey_returnsEmptyOptional() throws Exception { + Optional<RecoverableKeyStoreSecondaryKey> retrievedKey = + mRecoverableKeyStoreSecondaryKeyManager.get(NONEXISTENT_KEY_ALIAS); + + assertThat(retrievedKey.isPresent()).isFalse(); + } + + /** + * Ensure that exceptions occurring during {@link + * RecoverableKeyStoreSecondaryKeyManager#get(java.lang.String)} are not rethrown. + */ + @Test + public void get_encounteringInternalException_doesNotPropagateException() throws Exception { + ShadowRecoveryController.setThrowsInternalError(true); + + // Should not throw exception + mRecoverableKeyStoreSecondaryKeyManager.get(NONEXISTENT_KEY_ALIAS); + } + + /** Ensure that keys are correctly removed from the store. */ + @Test + public void remove_removesKeyFromRecoverableStore() throws Exception { + RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate(); + + mRecoverableKeyStoreSecondaryKeyManager.remove(key.getAlias()); + + assertThat(RecoveryController.getInstance(mContext).getAliases()) + .doesNotContain(key.getAlias()); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyTest.java new file mode 100644 index 000000000000..89977f82c145 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2018 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.keys; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.content.Context; +import android.platform.test.annotations.Presubmit; +import android.security.keystore.recovery.RecoveryController; + +import com.android.server.backup.encryption.keys.RecoverableKeyStoreSecondaryKey.Status; +import com.android.server.backup.testing.CryptoTestUtils; +import com.android.server.testing.shadows.ShadowInternalRecoveryServiceException; +import com.android.server.testing.shadows.ShadowRecoveryController; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import javax.crypto.SecretKey; + +/** Tests for {@link RecoverableKeyStoreSecondaryKey}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +@Config(shadows = {ShadowRecoveryController.class, ShadowInternalRecoveryServiceException.class}) +public class RecoverableKeyStoreSecondaryKeyTest { + private static final String TEST_ALIAS = "test"; + private static final int NONEXISTENT_STATUS_CODE = 42; + + private RecoverableKeyStoreSecondaryKey mSecondaryKey; + private SecretKey mGeneratedSecretKey; + private Context mContext; + + /** Instantiate a {@link RecoverableKeyStoreSecondaryKey} to use in tests. */ + @Before + public void setUp() throws Exception { + mContext = RuntimeEnvironment.application; + mGeneratedSecretKey = CryptoTestUtils.generateAesKey(); + mSecondaryKey = new RecoverableKeyStoreSecondaryKey(TEST_ALIAS, mGeneratedSecretKey); + } + + /** Reset the {@link ShadowRecoveryController}. */ + @After + public void tearDown() throws Exception { + ShadowRecoveryController.reset(); + } + + /** + * Checks that {@link RecoverableKeyStoreSecondaryKey#getAlias()} returns the value supplied in + * the constructor. + */ + @Test + public void getAlias() { + String alias = mSecondaryKey.getAlias(); + + assertThat(alias).isEqualTo(TEST_ALIAS); + } + + /** + * Checks that {@link RecoverableKeyStoreSecondaryKey#getSecretKey()} returns the value supplied + * in the constructor. + */ + @Test + public void getSecretKey() { + SecretKey secretKey = mSecondaryKey.getSecretKey(); + + assertThat(secretKey).isEqualTo(mGeneratedSecretKey); + } + + /** + * Checks that passing a secret key that is null to the constructor throws an exception. + */ + @Test + public void constructor_withNullSecretKey_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> new RecoverableKeyStoreSecondaryKey(TEST_ALIAS, null)); + } + + /** + * Checks that passing an alias that is null to the constructor throws an exception. + */ + @Test + public void constructor_withNullAlias_throwsNullPointerException() { + assertThrows( + NullPointerException.class, + () -> new RecoverableKeyStoreSecondaryKey(null, mGeneratedSecretKey)); + } + + /** Checks that the synced status is returned correctly. */ + @Test + public void getStatus_whenSynced_returnsSynced() throws Exception { + setStatus(RecoveryController.RECOVERY_STATUS_SYNCED); + + int status = mSecondaryKey.getStatus(mContext); + + assertThat(status).isEqualTo(Status.SYNCED); + } + + /** Checks that the in progress sync status is returned correctly. */ + @Test + public void getStatus_whenNotSynced_returnsNotSynced() throws Exception { + setStatus(RecoveryController.RECOVERY_STATUS_SYNC_IN_PROGRESS); + + int status = mSecondaryKey.getStatus(mContext); + + assertThat(status).isEqualTo(Status.NOT_SYNCED); + } + + /** Checks that the failure status is returned correctly. */ + @Test + public void getStatus_onPermanentFailure_returnsDestroyed() throws Exception { + setStatus(RecoveryController.RECOVERY_STATUS_PERMANENT_FAILURE); + + int status = mSecondaryKey.getStatus(mContext); + + assertThat(status).isEqualTo(Status.DESTROYED); + } + + /** Checks that an unknown status results in {@code NOT_SYNCED} being returned. */ + @Test + public void getStatus_forUnknownStatusCode_returnsNotSynced() throws Exception { + setStatus(NONEXISTENT_STATUS_CODE); + + int status = mSecondaryKey.getStatus(mContext); + + assertThat(status).isEqualTo(Status.NOT_SYNCED); + } + + /** Checks that an internal error results in {@code NOT_SYNCED} being returned. */ + @Test + public void getStatus_onInternalError_returnsNotSynced() throws Exception { + ShadowRecoveryController.setThrowsInternalError(true); + + int status = mSecondaryKey.getStatus(mContext); + + assertThat(status).isEqualTo(Status.NOT_SYNCED); + } + + private void setStatus(int status) throws Exception { + ShadowRecoveryController.setRecoveryStatus(TEST_ALIAS, status); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RestoreKeyFetcherTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RestoreKeyFetcherTest.java new file mode 100644 index 000000000000..004f8097ce39 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RestoreKeyFetcherTest.java @@ -0,0 +1,127 @@ +/* + * 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.keys; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertThrows; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.security.InvalidKeyException; +import java.security.KeyException; +import java.security.SecureRandom; +import java.util.Optional; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +/** Test the restore key fetcher */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class RestoreKeyFetcherTest { + + private static final String KEY_GENERATOR_ALGORITHM = "AES"; + + private static final String TEST_SECONDARY_KEY_ALIAS = "test_2ndary_key"; + private static final byte[] TEST_SECONDARY_KEY_BYTES = new byte[256 / Byte.SIZE]; + + @Mock private RecoverableKeyStoreSecondaryKeyManager mSecondaryKeyManager; + + /** Initialise the mocks **/ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + /** Ensure the unwrap method works as expected */ + @Test + public void unwrapTertiaryKey_returnsUnwrappedKey() throws Exception { + RecoverableKeyStoreSecondaryKey secondaryKey = createSecondaryKey(); + SecretKey tertiaryKey = createTertiaryKey(); + WrappedKeyProto.WrappedKey wrappedTertiaryKey = + KeyWrapUtils.wrap(secondaryKey.getSecretKey(), tertiaryKey); + when(mSecondaryKeyManager.get(TEST_SECONDARY_KEY_ALIAS)) + .thenReturn(Optional.of(secondaryKey)); + + SecretKey actualTertiaryKey = + RestoreKeyFetcher.unwrapTertiaryKey( + () -> mSecondaryKeyManager, + TEST_SECONDARY_KEY_ALIAS, + wrappedTertiaryKey); + + assertThat(actualTertiaryKey).isEqualTo(tertiaryKey); + } + + /** Ensure that missing secondary keys are detected and an appropriate exception is thrown */ + @Test + public void unwrapTertiaryKey_missingSecondaryKey_throwsSpecificException() throws Exception { + WrappedKeyProto.WrappedKey wrappedTertiaryKey = + KeyWrapUtils.wrap(createSecondaryKey().getSecretKey(), createTertiaryKey()); + when(mSecondaryKeyManager.get(TEST_SECONDARY_KEY_ALIAS)).thenReturn(Optional.empty()); + + assertThrows( + KeyException.class, + () -> + RestoreKeyFetcher.unwrapTertiaryKey( + () -> mSecondaryKeyManager, + TEST_SECONDARY_KEY_ALIAS, + wrappedTertiaryKey)); + } + + /** Ensure that invalid secondary keys are detected and an appropriate exception is thrown */ + @Test + public void unwrapTertiaryKey_badSecondaryKey_throws() throws Exception { + RecoverableKeyStoreSecondaryKey badSecondaryKey = + new RecoverableKeyStoreSecondaryKey( + TEST_SECONDARY_KEY_ALIAS, + new SecretKeySpec(new byte[] {0, 1}, KEY_GENERATOR_ALGORITHM)); + + WrappedKeyProto.WrappedKey wrappedTertiaryKey = + KeyWrapUtils.wrap(createSecondaryKey().getSecretKey(), createTertiaryKey()); + when(mSecondaryKeyManager.get(TEST_SECONDARY_KEY_ALIAS)) + .thenReturn(Optional.of(badSecondaryKey)); + + assertThrows( + InvalidKeyException.class, + () -> + RestoreKeyFetcher.unwrapTertiaryKey( + () -> mSecondaryKeyManager, + TEST_SECONDARY_KEY_ALIAS, + wrappedTertiaryKey)); + } + + private static RecoverableKeyStoreSecondaryKey createSecondaryKey() { + return new RecoverableKeyStoreSecondaryKey( + TEST_SECONDARY_KEY_ALIAS, + new SecretKeySpec(TEST_SECONDARY_KEY_BYTES, KEY_GENERATOR_ALGORITHM)); + } + + private static SecretKey createTertiaryKey() { + return new TertiaryKeyGenerator(new SecureRandom(new byte[] {0})).generate(); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyGeneratorTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyGeneratorTest.java new file mode 100644 index 000000000000..48216f8d7aca --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyGeneratorTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2018 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.keys; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.security.SecureRandom; + +import javax.crypto.SecretKey; + +/** Tests for {@link TertiaryKeyGenerator}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class TertiaryKeyGeneratorTest { + private static final String KEY_ALGORITHM = "AES"; + private static final int KEY_SIZE_BITS = 256; + + private TertiaryKeyGenerator mTertiaryKeyGenerator; + + /** Instantiate a new {@link TertiaryKeyGenerator} for use in tests. */ + @Before + public void setUp() { + mTertiaryKeyGenerator = new TertiaryKeyGenerator(new SecureRandom()); + } + + /** Generated keys should be AES keys. */ + @Test + public void generate_generatesAESKeys() { + SecretKey secretKey = mTertiaryKeyGenerator.generate(); + + assertThat(secretKey.getAlgorithm()).isEqualTo(KEY_ALGORITHM); + } + + /** Generated keys should be 256 bits in size. */ + @Test + public void generate_generates256BitKeys() { + SecretKey secretKey = mTertiaryKeyGenerator.generate(); + + assertThat(secretKey.getEncoded()).hasLength(KEY_SIZE_BITS / 8); + } + + /** + * Subsequent calls to {@link TertiaryKeyGenerator#generate()} should generate different keys. + */ + @Test + public void generate_generatesNewKeys() { + SecretKey key1 = mTertiaryKeyGenerator.generate(); + SecretKey key2 = mTertiaryKeyGenerator.generate(); + + assertThat(key1).isNotEqualTo(key2); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationSchedulerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationSchedulerTest.java new file mode 100644 index 000000000000..dfc7e2bfd4f7 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationSchedulerTest.java @@ -0,0 +1,200 @@ +/* + * 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.keys; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; +import static org.robolectric.RuntimeEnvironment.application; + +import android.content.Context; + +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.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.io.File; +import java.time.Clock; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** Tests for the tertiary key rotation scheduler */ +@RunWith(RobolectricTestRunner.class) +public final class TertiaryKeyRotationSchedulerTest { + + private static final int MAXIMUM_ROTATIONS_PER_WINDOW = 2; + private static final int MAX_BACKUPS_TILL_ROTATION = 31; + private static final String SHARED_PREFS_NAME = "tertiary_key_rotation_tracker"; + private static final String PACKAGE_1 = "com.android.example1"; + private static final String PACKAGE_2 = "com.android.example2"; + + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock private Clock mClock; + + private File mFile; + private TertiaryKeyRotationScheduler mScheduler; + + /** Setup the scheduler for test */ + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + mFile = temporaryFolder.newFile(); + mScheduler = + new TertiaryKeyRotationScheduler( + new TertiaryKeyRotationTracker( + application.getSharedPreferences( + SHARED_PREFS_NAME, Context.MODE_PRIVATE), + MAX_BACKUPS_TILL_ROTATION), + new TertiaryKeyRotationWindowedCount(mFile, mClock), + MAXIMUM_ROTATIONS_PER_WINDOW); + } + + /** Test we don't trigger a rotation straight off */ + @Test + public void isKeyRotationDue_isFalseInitially() { + assertThat(mScheduler.isKeyRotationDue(PACKAGE_1)).isFalse(); + } + + /** Test we don't prematurely trigger a rotation */ + @Test + public void isKeyRotationDue_isFalseAfterInsufficientBackups() { + simulateBackups(MAX_BACKUPS_TILL_ROTATION - 1); + assertThat(mScheduler.isKeyRotationDue(PACKAGE_1)).isFalse(); + } + + /** Test we do trigger a backup */ + @Test + public void isKeyRotationDue_isTrueAfterEnoughBackups() { + simulateBackups(MAX_BACKUPS_TILL_ROTATION); + assertThat(mScheduler.isKeyRotationDue(PACKAGE_1)).isTrue(); + } + + /** Test rotation will occur if the quota allows */ + @Test + public void isKeyRotationDue_isTrueIfRotationQuotaRemainsInWindow() { + simulateBackups(MAX_BACKUPS_TILL_ROTATION); + mScheduler.recordKeyRotation(PACKAGE_2); + assertThat(mScheduler.isKeyRotationDue(PACKAGE_1)).isTrue(); + } + + /** Test rotation is blocked if the quota has been exhausted */ + @Test + public void isKeyRotationDue_isFalseIfEnoughRotationsHaveHappenedInWindow() { + simulateBackups(MAX_BACKUPS_TILL_ROTATION); + mScheduler.recordKeyRotation(PACKAGE_2); + mScheduler.recordKeyRotation(PACKAGE_2); + assertThat(mScheduler.isKeyRotationDue(PACKAGE_1)).isFalse(); + } + + /** Test rotation is due after one window has passed */ + @Test + public void isKeyRotationDue_isTrueAfterAWholeWindowHasPassed() { + simulateBackups(MAX_BACKUPS_TILL_ROTATION); + mScheduler.recordKeyRotation(PACKAGE_2); + mScheduler.recordKeyRotation(PACKAGE_2); + setTimeMillis(TimeUnit.HOURS.toMillis(24)); + assertThat(mScheduler.isKeyRotationDue(PACKAGE_1)).isTrue(); + } + + /** Test the rotation state changes after a rotation */ + @Test + public void isKeyRotationDue_isFalseAfterRotation() { + simulateBackups(MAX_BACKUPS_TILL_ROTATION); + mScheduler.recordKeyRotation(PACKAGE_1); + assertThat(mScheduler.isKeyRotationDue(PACKAGE_1)).isFalse(); + } + + /** Test the rate limiting for a given window */ + @Test + public void isKeyRotationDue_neverAllowsMoreThanInWindow() { + List<String> apps = makeTestApps(MAXIMUM_ROTATIONS_PER_WINDOW * MAX_BACKUPS_TILL_ROTATION); + + // simulate backups of all apps each night + for (int i = 0; i < 300; i++) { + setTimeMillis(i * TimeUnit.HOURS.toMillis(24)); + int rotationsThisNight = 0; + for (String app : apps) { + if (mScheduler.isKeyRotationDue(app)) { + rotationsThisNight++; + mScheduler.recordKeyRotation(app); + } else { + mScheduler.recordBackup(app); + } + } + assertThat(rotationsThisNight).isAtMost(MAXIMUM_ROTATIONS_PER_WINDOW); + } + } + + /** Test that backups are staggered over the window */ + @Test + public void isKeyRotationDue_naturallyStaggersBackupsOverTime() { + List<String> apps = makeTestApps(MAXIMUM_ROTATIONS_PER_WINDOW * MAX_BACKUPS_TILL_ROTATION); + + HashMap<String, ArrayList<Integer>> rotationDays = new HashMap<>(); + for (String app : apps) { + rotationDays.put(app, new ArrayList<>()); + } + + // simulate backups of all apps each night + for (int i = 0; i < 300; i++) { + setTimeMillis(i * TimeUnit.HOURS.toMillis(24)); + for (String app : apps) { + if (mScheduler.isKeyRotationDue(app)) { + rotationDays.get(app).add(i); + mScheduler.recordKeyRotation(app); + } else { + mScheduler.recordBackup(app); + } + } + } + + for (String app : apps) { + List<Integer> days = rotationDays.get(app); + for (int i = 1; i < days.size(); i++) { + assertThat(days.get(i) - days.get(i - 1)).isEqualTo(MAX_BACKUPS_TILL_ROTATION + 1); + } + } + } + + private ArrayList<String> makeTestApps(int n) { + ArrayList<String> apps = new ArrayList<>(); + for (int i = 0; i < n; i++) { + apps.add(String.format(Locale.US, "com.android.app%d", i)); + } + return apps; + } + + private void simulateBackups(int numberOfBackups) { + while (numberOfBackups > 0) { + mScheduler.recordBackup(PACKAGE_1); + numberOfBackups--; + } + } + + private void setTimeMillis(long timeMillis) { + when(mClock.millis()).thenReturn(timeMillis); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTrackerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTrackerTest.java new file mode 100644 index 000000000000..49bb410ceb65 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTrackerTest.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2018 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.keys; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Tests for {@link TertiaryKeyRotationTracker}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class TertiaryKeyRotationTrackerTest { + private static final String PACKAGE_1 = "com.package.one"; + private static final int NUMBER_OF_BACKUPS_BEFORE_ROTATION = 31; + + private TertiaryKeyRotationTracker mTertiaryKeyRotationTracker; + + /** Instantiate a {@link TertiaryKeyRotationTracker} for use in tests. */ + @Before + public void setUp() { + mTertiaryKeyRotationTracker = newInstance(); + } + + /** New packages should not be due for key rotation. */ + @Test + public void isKeyRotationDue_forNewPackage_isFalse() { + // Simulate a new package by not calling simulateBackups(). As a result, PACKAGE_1 hasn't + // been seen by mTertiaryKeyRotationTracker before. + boolean keyRotationDue = mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1); + + assertThat(keyRotationDue).isFalse(); + } + + /** + * Key rotation should not be due after less than {@code NUMBER_OF_BACKUPS_BEFORE_ROTATION} + * backups. + */ + @Test + public void isKeyRotationDue_afterLessThanRotationAmountBackups_isFalse() { + simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION - 1); + + boolean keyRotationDue = mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1); + + assertThat(keyRotationDue).isFalse(); + } + + /** Key rotation should be due after {@code NUMBER_OF_BACKUPS_BEFORE_ROTATION} backups. */ + @Test + public void isKeyRotationDue_afterRotationAmountBackups_isTrue() { + simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION); + + boolean keyRotationDue = mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1); + + assertThat(keyRotationDue).isTrue(); + } + + /** + * A call to {@link TertiaryKeyRotationTracker#resetCountdown(String)} should make sure no key + * rotation is due. + */ + @Test + public void resetCountdown_makesKeyRotationNotDue() { + simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION); + + mTertiaryKeyRotationTracker.resetCountdown(PACKAGE_1); + + assertThat(mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1)).isFalse(); + } + + /** + * New instances of {@link TertiaryKeyRotationTracker} should read state about the number of + * backups from disk. + */ + @Test + public void isKeyRotationDue_forNewInstance_readsStateFromDisk() { + simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION); + + boolean keyRotationDueForNewInstance = newInstance().isKeyRotationDue(PACKAGE_1); + + assertThat(keyRotationDueForNewInstance).isTrue(); + } + + /** + * A call to {@link TertiaryKeyRotationTracker#markAllForRotation()} should mark all previously + * seen packages for rotation. + */ + @Test + public void markAllForRotation_marksSeenPackagesForKeyRotation() { + simulateBackups(PACKAGE_1, /*numberOfBackups=*/ 1); + + mTertiaryKeyRotationTracker.markAllForRotation(); + + assertThat(mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1)).isTrue(); + } + + /** + * A call to {@link TertiaryKeyRotationTracker#markAllForRotation()} should not mark any new + * packages for rotation. + */ + @Test + public void markAllForRotation_doesNotMarkUnseenPackages() { + mTertiaryKeyRotationTracker.markAllForRotation(); + + assertThat(mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1)).isFalse(); + } + + private void simulateBackups(String packageName, int numberOfBackups) { + while (numberOfBackups > 0) { + mTertiaryKeyRotationTracker.recordBackup(packageName); + numberOfBackups--; + } + } + + private static TertiaryKeyRotationTracker newInstance() { + return TertiaryKeyRotationTracker.getInstance(RuntimeEnvironment.application); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationWindowedCountTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationWindowedCountTest.java new file mode 100644 index 000000000000..bd309779f303 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationWindowedCountTest.java @@ -0,0 +1,131 @@ +/* + * 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.keys; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; + +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.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.io.File; +import java.io.IOException; +import java.time.Clock; +import java.util.concurrent.TimeUnit; + +/** Tests for {@link TertiaryKeyRotationWindowedCount}. */ +@RunWith(RobolectricTestRunner.class) +public class TertiaryKeyRotationWindowedCountTest { + private static final int TIMESTAMP_SIZE_IN_BYTES = 8; + + @Rule public final TemporaryFolder mTemporaryFolder = new TemporaryFolder(); + + @Mock private Clock mClock; + + private File mFile; + private TertiaryKeyRotationWindowedCount mWindowedcount; + + /** Setup the windowed counter for testing */ + @Before + public void setUp() throws IOException { + MockitoAnnotations.initMocks(this); + mFile = mTemporaryFolder.newFile(); + mWindowedcount = new TertiaryKeyRotationWindowedCount(mFile, mClock); + } + + /** Test handling bad files */ + @Test + public void constructor_doesNotFailForBadFile() throws IOException { + new TertiaryKeyRotationWindowedCount(mTemporaryFolder.newFolder(), mClock); + } + + /** Test the count is 0 to start */ + @Test + public void getCount_isZeroInitially() { + assertThat(mWindowedcount.getCount()).isEqualTo(0); + } + + /** Test the count is correct for a time window */ + @Test + public void getCount_includesResultsInLastTwentyFourHours() { + setTimeMillis(0); + mWindowedcount.record(); + setTimeMillis(TimeUnit.HOURS.toMillis(4)); + mWindowedcount.record(); + setTimeMillis(TimeUnit.HOURS.toMillis(23)); + mWindowedcount.record(); + mWindowedcount.record(); + assertThat(mWindowedcount.getCount()).isEqualTo(4); + } + + /** Test old results are ignored */ + @Test + public void getCount_ignoresResultsOlderThanTwentyFourHours() { + setTimeMillis(0); + mWindowedcount.record(); + setTimeMillis(TimeUnit.HOURS.toMillis(24)); + assertThat(mWindowedcount.getCount()).isEqualTo(0); + } + + /** Test future events are removed if the clock moves backways (e.g. DST, TZ change) */ + @Test + public void getCount_removesFutureEventsIfClockHasChanged() { + setTimeMillis(1000); + mWindowedcount.record(); + setTimeMillis(0); + assertThat(mWindowedcount.getCount()).isEqualTo(0); + } + + /** Check recording doesn't fail for a bad file */ + @Test + public void record_doesNotFailForBadFile() throws Exception { + new TertiaryKeyRotationWindowedCount(mTemporaryFolder.newFolder(), mClock).record(); + } + + /** Checks the state is persisted */ + @Test + public void record_persistsStateToDisk() { + setTimeMillis(0); + mWindowedcount.record(); + assertThat(new TertiaryKeyRotationWindowedCount(mFile, mClock).getCount()).isEqualTo(1); + } + + /** Test the file doesn't contain unnecessary data */ + @Test + public void record_compactsFileToLast24Hours() { + setTimeMillis(0); + mWindowedcount.record(); + assertThat(mFile.length()).isEqualTo(TIMESTAMP_SIZE_IN_BYTES); + setTimeMillis(1); + mWindowedcount.record(); + assertThat(mFile.length()).isEqualTo(2 * TIMESTAMP_SIZE_IN_BYTES); + setTimeMillis(TimeUnit.HOURS.toMillis(24)); + mWindowedcount.record(); + assertThat(mFile.length()).isEqualTo(2 * TIMESTAMP_SIZE_IN_BYTES); + } + + private void setTimeMillis(long timeMillis) { + when(mClock.millis()).thenReturn(timeMillis); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyStoreTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyStoreTest.java new file mode 100644 index 000000000000..ccc5f32dad36 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyStoreTest.java @@ -0,0 +1,213 @@ +/* + * 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.keys; + +import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; + +import android.content.Context; + +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.security.InvalidKeyException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import javax.crypto.SecretKey; + +/** Tests for the tertiary key store */ +@RunWith(RobolectricTestRunner.class) +public class TertiaryKeyStoreTest { + + private static final String SECONDARY_KEY_ALIAS = "Robbo/Ranx"; + + private Context mApplication; + private TertiaryKeyStore mTertiaryKeyStore; + private SecretKey mSecretKey; + + /** Initialise the keystore for testing */ + @Before + public void setUp() throws Exception { + mApplication = RuntimeEnvironment.application; + mSecretKey = generateAesKey(); + mTertiaryKeyStore = + TertiaryKeyStore.newInstance( + mApplication, + new RecoverableKeyStoreSecondaryKey(SECONDARY_KEY_ALIAS, mSecretKey)); + } + + /** Test a reound trip for a key */ + @Test + public void load_loadsAKeyThatWasSaved() throws Exception { + String packageName = "com.android.example"; + SecretKey packageKey = generateAesKey(); + mTertiaryKeyStore.save(packageName, packageKey); + + Optional<SecretKey> maybeLoadedKey = mTertiaryKeyStore.load(packageName); + + assertTrue(maybeLoadedKey.isPresent()); + assertEquals(packageKey, maybeLoadedKey.get()); + } + + /** Test isolation between packages */ + @Test + public void load_doesNotLoadAKeyForAnotherSecondary() throws Exception { + String packageName = "com.android.example"; + SecretKey packageKey = generateAesKey(); + mTertiaryKeyStore.save(packageName, packageKey); + TertiaryKeyStore managerWithOtherSecondaryKey = + TertiaryKeyStore.newInstance( + mApplication, + new RecoverableKeyStoreSecondaryKey( + "myNewSecondaryKeyAlias", generateAesKey())); + + assertFalse(managerWithOtherSecondaryKey.load(packageName).isPresent()); + } + + /** Test non-existent key handling */ + @Test + public void load_returnsAbsentForANonExistentKey() throws Exception { + assertFalse(mTertiaryKeyStore.load("mystery.package").isPresent()); + } + + /** Test handling incorrect keys */ + @Test + public void load_throwsIfHasWrongBackupKey() throws Exception { + String packageName = "com.android.example"; + SecretKey packageKey = generateAesKey(); + mTertiaryKeyStore.save(packageName, packageKey); + TertiaryKeyStore managerWithBadKey = + TertiaryKeyStore.newInstance( + mApplication, + new RecoverableKeyStoreSecondaryKey(SECONDARY_KEY_ALIAS, generateAesKey())); + + assertThrows(InvalidKeyException.class, () -> managerWithBadKey.load(packageName)); + } + + /** Test handling of empty app name */ + @Test + public void load_throwsForEmptyApplicationName() throws Exception { + assertThrows(IllegalArgumentException.class, () -> mTertiaryKeyStore.load("")); + } + + /** Test handling of an invalid app name */ + @Test + public void load_throwsForBadApplicationName() throws Exception { + assertThrows( + IllegalArgumentException.class, + () -> mTertiaryKeyStore.load("com/android/example")); + } + + /** Test key replacement */ + @Test + public void save_overwritesPreviousKey() throws Exception { + String packageName = "com.android.example"; + SecretKey oldKey = generateAesKey(); + mTertiaryKeyStore.save(packageName, oldKey); + SecretKey newKey = generateAesKey(); + + mTertiaryKeyStore.save(packageName, newKey); + + Optional<SecretKey> maybeLoadedKey = mTertiaryKeyStore.load(packageName); + assertTrue(maybeLoadedKey.isPresent()); + SecretKey loadedKey = maybeLoadedKey.get(); + assertThat(loadedKey).isNotEqualTo(oldKey); + assertThat(loadedKey).isEqualTo(newKey); + } + + /** Test saving with an empty application name fails */ + @Test + public void save_throwsForEmptyApplicationName() throws Exception { + assertThrows( + IllegalArgumentException.class, () -> mTertiaryKeyStore.save("", generateAesKey())); + } + + /** Test saving an invalid application name fails */ + @Test + public void save_throwsForBadApplicationName() throws Exception { + assertThrows( + IllegalArgumentException.class, + () -> mTertiaryKeyStore.save("com/android/example", generateAesKey())); + } + + /** Test handling an empty database */ + @Test + public void getAll_returnsEmptyMapForEmptyDb() throws Exception { + assertThat(mTertiaryKeyStore.getAll()).isEmpty(); + } + + /** Test loading all available keys works as expected */ + @Test + public void getAll_returnsAllKeysSaved() throws Exception { + String package1 = "com.android.example"; + SecretKey key1 = generateAesKey(); + String package2 = "com.anndroid.example1"; + SecretKey key2 = generateAesKey(); + String package3 = "com.android.example2"; + SecretKey key3 = generateAesKey(); + mTertiaryKeyStore.save(package1, key1); + mTertiaryKeyStore.save(package2, key2); + mTertiaryKeyStore.save(package3, key3); + + Map<String, SecretKey> keys = mTertiaryKeyStore.getAll(); + + assertThat(keys).containsExactly(package1, key1, package2, key2, package3, key3); + } + + /** Test cross-secondary isolation */ + @Test + public void getAll_doesNotReturnKeysForOtherSecondary() throws Exception { + String packageName = "com.android.example"; + TertiaryKeyStore managerWithOtherSecondaryKey = + TertiaryKeyStore.newInstance( + mApplication, + new RecoverableKeyStoreSecondaryKey( + "myNewSecondaryKeyAlias", generateAesKey())); + managerWithOtherSecondaryKey.save(packageName, generateAesKey()); + + assertThat(mTertiaryKeyStore.getAll()).isEmpty(); + } + + /** Test mass put into the keystore */ + @Test + public void putAll_putsAllWrappedKeysInTheStore() throws Exception { + String packageName = "com.android.example"; + SecretKey key = generateAesKey(); + WrappedKeyProto.WrappedKey wrappedKey = KeyWrapUtils.wrap(mSecretKey, key); + + Map<String, WrappedKeyProto.WrappedKey> testElements = new HashMap<>(); + testElements.put(packageName, wrappedKey); + mTertiaryKeyStore.putAll(testElements); + + assertThat(mTertiaryKeyStore.getAll()).containsKey(packageName); + assertThat(mTertiaryKeyStore.getAll().get(packageName).getEncoded()) + .isEqualTo(key.getEncoded()); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/BackupEncryptionDbTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/BackupEncryptionDbTest.java new file mode 100644 index 000000000000..87f21bfa59c2 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/BackupEncryptionDbTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2018 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.storage; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Tests for {@link BackupEncryptionDb}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class BackupEncryptionDbTest { + private BackupEncryptionDb mBackupEncryptionDb; + + /** Creates an empty {@link BackupEncryptionDb} */ + @Before + public void setUp() { + mBackupEncryptionDb = BackupEncryptionDb.newInstance(RuntimeEnvironment.application); + } + + /** + * Tests that the tertiary keys table gets cleared when calling {@link + * BackupEncryptionDb#clear()}. + */ + @Test + public void clear_withNonEmptyTertiaryKeysTable_clearsTertiaryKeysTable() throws Exception { + String secondaryKeyAlias = "secondaryKeyAlias"; + TertiaryKeysTable tertiaryKeysTable = mBackupEncryptionDb.getTertiaryKeysTable(); + tertiaryKeysTable.addKey(new TertiaryKey(secondaryKeyAlias, "packageName", new byte[0])); + + mBackupEncryptionDb.clear(); + + assertThat(tertiaryKeysTable.getAllKeys(secondaryKeyAlias)).isEmpty(); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/TertiaryKeysTableTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/TertiaryKeysTableTest.java new file mode 100644 index 000000000000..319ec89f445e --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/TertiaryKeysTableTest.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2018 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.storage; + +import static com.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.testing.CryptoTestUtils; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.Map; +import java.util.Optional; + +/** Tests for {@link TertiaryKeysTable}. */ +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class TertiaryKeysTableTest { + private static final int KEY_SIZE_BYTES = 32; + private static final String SECONDARY_ALIAS = "phoebe"; + private static final String PACKAGE_NAME = "generic.package.name"; + + private TertiaryKeysTable mTertiaryKeysTable; + + /** Creates an empty {@link BackupEncryptionDb}. */ + @Before + public void setUp() { + mTertiaryKeysTable = + BackupEncryptionDb.newInstance(RuntimeEnvironment.application) + .getTertiaryKeysTable(); + } + + /** Tests that new {@link TertiaryKey}s get successfully added to the database. */ + @Test + public void addKey_onEmptyDatabase_putsKeyInDb() throws Exception { + byte[] key = generateRandomKey(); + TertiaryKey keyToInsert = new TertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME, key); + + long result = mTertiaryKeysTable.addKey(keyToInsert); + + assertThat(result).isNotEqualTo(-1); + Optional<TertiaryKey> maybeKeyInDb = + mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME); + assertThat(maybeKeyInDb.isPresent()).isTrue(); + TertiaryKey keyInDb = maybeKeyInDb.get(); + assertTertiaryKeysEqual(keyInDb, keyToInsert); + } + + /** Tests that keys replace older keys with the same secondary alias and package name. */ + @Test + public void addKey_havingSameSecondaryAliasAndPackageName_replacesOldKey() throws Exception { + mTertiaryKeysTable.addKey( + new TertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME, generateRandomKey())); + byte[] newKey = generateRandomKey(); + + long result = + mTertiaryKeysTable.addKey(new TertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME, newKey)); + + assertThat(result).isNotEqualTo(-1); + TertiaryKey keyInDb = mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME).get(); + assertThat(keyInDb.getWrappedKeyBytes()).isEqualTo(newKey); + } + + /** + * Tests that keys do not replace older keys with the same package name but a different alias. + */ + @Test + public void addKey_havingSamePackageNameButDifferentAlias_doesNotReplaceOldKey() + throws Exception { + String alias2 = "karl"; + TertiaryKey key1 = generateTertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME); + TertiaryKey key2 = generateTertiaryKey(alias2, PACKAGE_NAME); + + long primaryKey1 = mTertiaryKeysTable.addKey(key1); + long primaryKey2 = mTertiaryKeysTable.addKey(key2); + + assertThat(primaryKey1).isNotEqualTo(primaryKey2); + assertThat(mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME).isPresent()).isTrue(); + assertTertiaryKeysEqual( + mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME).get(), key1); + assertThat(mTertiaryKeysTable.getKey(alias2, PACKAGE_NAME).isPresent()).isTrue(); + assertTertiaryKeysEqual(mTertiaryKeysTable.getKey(alias2, PACKAGE_NAME).get(), key2); + } + + /** + * Tests that {@link TertiaryKeysTable#getKey(String, String)} returns an empty {@link Optional} + * for a missing key. + */ + @Test + public void getKey_forMissingKey_returnsEmptyOptional() throws Exception { + Optional<TertiaryKey> key = mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME); + + assertThat(key.isPresent()).isFalse(); + } + + /** + * Tests that {@link TertiaryKeysTable#getAllKeys(String)} returns an empty map when no keys + * with the secondary alias exist. + */ + @Test + public void getAllKeys_withNoKeysForAlias_returnsEmptyMap() throws Exception { + assertThat(mTertiaryKeysTable.getAllKeys(SECONDARY_ALIAS)).isEmpty(); + } + + /** + * Tests that {@link TertiaryKeysTable#getAllKeys(String)} returns all keys corresponding to the + * provided secondary alias. + */ + @Test + public void getAllKeys_withMatchingKeys_returnsAllKeysWrappedWithSecondary() throws Exception { + TertiaryKey key1 = generateTertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME); + mTertiaryKeysTable.addKey(key1); + String package2 = "generic.package.two"; + TertiaryKey key2 = generateTertiaryKey(SECONDARY_ALIAS, package2); + mTertiaryKeysTable.addKey(key2); + String package3 = "generic.package.three"; + TertiaryKey key3 = generateTertiaryKey(SECONDARY_ALIAS, package3); + mTertiaryKeysTable.addKey(key3); + + Map<String, TertiaryKey> keysByPackageName = mTertiaryKeysTable.getAllKeys(SECONDARY_ALIAS); + + assertThat(keysByPackageName).hasSize(3); + assertThat(keysByPackageName).containsKey(PACKAGE_NAME); + assertTertiaryKeysEqual(keysByPackageName.get(PACKAGE_NAME), key1); + assertThat(keysByPackageName).containsKey(package2); + assertTertiaryKeysEqual(keysByPackageName.get(package2), key2); + assertThat(keysByPackageName).containsKey(package3); + assertTertiaryKeysEqual(keysByPackageName.get(package3), key3); + } + + /** + * Tests that {@link TertiaryKeysTable#getAllKeys(String)} does not return any keys wrapped with + * another alias. + */ + @Test + public void getAllKeys_withMatchingKeys_doesNotReturnKeysWrappedWithOtherAlias() + throws Exception { + mTertiaryKeysTable.addKey(generateTertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME)); + mTertiaryKeysTable.addKey(generateTertiaryKey("somekey", "generic.package.two")); + + Map<String, TertiaryKey> keysByPackageName = mTertiaryKeysTable.getAllKeys(SECONDARY_ALIAS); + + assertThat(keysByPackageName).hasSize(1); + assertThat(keysByPackageName).containsKey(PACKAGE_NAME); + } + + private void assertTertiaryKeysEqual(TertiaryKey a, TertiaryKey b) { + assertThat(a.getSecondaryKeyAlias()).isEqualTo(b.getSecondaryKeyAlias()); + assertThat(a.getPackageName()).isEqualTo(b.getPackageName()); + assertThat(a.getWrappedKeyBytes()).isEqualTo(b.getWrappedKeyBytes()); + } + + private TertiaryKey generateTertiaryKey(String alias, String packageName) { + return new TertiaryKey(alias, packageName, generateRandomKey()); + } + + private byte[] generateRandomKey() { + return CryptoTestUtils.generateRandomBytes(KEY_SIZE_BYTES); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypterTest.java new file mode 100644 index 000000000000..21c4e07577da --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypterTest.java @@ -0,0 +1,262 @@ +/* + * 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.google.common.truth.Truth.assertThat; + +import android.platform.test.annotations.Presubmit; + +import com.android.server.backup.encryption.chunk.ChunkHash; +import com.android.server.backup.encryption.chunking.EncryptedChunk; +import com.android.server.backup.testing.CryptoTestUtils; +import com.android.server.backup.testing.RandomInputStream; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayInputStream; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Random; + +import javax.crypto.SecretKey; + +@RunWith(RobolectricTestRunner.class) +@Presubmit +public class BackupStreamEncrypterTest { + private static final int SALT_LENGTH = 32; + private static final int BITS_PER_BYTE = 8; + private static final int BYTES_PER_KILOBYTE = 1024; + private static final int BYTES_PER_MEGABYTE = 1024 * 1024; + private static final int MIN_CHUNK_SIZE = 2 * BYTES_PER_KILOBYTE; + private static final int AVERAGE_CHUNK_SIZE = 4 * BYTES_PER_KILOBYTE; + private static final int MAX_CHUNK_SIZE = 64 * BYTES_PER_KILOBYTE; + private static final int BACKUP_SIZE = 2 * BYTES_PER_MEGABYTE; + private static final int SMALL_BACKUP_SIZE = BYTES_PER_KILOBYTE; + // 16 bytes for the mac. iv is encoded in a separate field. + private static final int BYTES_OVERHEAD_PER_CHUNK = 16; + private static final int MESSAGE_DIGEST_SIZE_IN_BYTES = 256 / BITS_PER_BYTE; + private static final int RANDOM_SEED = 42; + private static final double TOLERANCE = 0.1; + + private Random mRandom; + private SecretKey mSecretKey; + private byte[] mSalt; + + @Before + public void setUp() throws Exception { + mSecretKey = CryptoTestUtils.generateAesKey(); + + mSalt = new byte[SALT_LENGTH]; + // Make these tests deterministic + mRandom = new Random(RANDOM_SEED); + mRandom.nextBytes(mSalt); + } + + @Test + public void testBackup_producesChunksOfTheGivenAverageSize() throws Exception { + BackupEncrypter.Result result = runBackup(BACKUP_SIZE); + + long totalSize = 0; + for (EncryptedChunk chunk : result.getNewChunks()) { + totalSize += chunk.encryptedBytes().length; + } + + double meanSize = totalSize / result.getNewChunks().size(); + double expectedChunkSize = AVERAGE_CHUNK_SIZE + BYTES_OVERHEAD_PER_CHUNK; + assertThat(Math.abs(meanSize - expectedChunkSize) / expectedChunkSize) + .isLessThan(TOLERANCE); + } + + @Test + public void testBackup_producesNoChunksSmallerThanMinSize() throws Exception { + BackupEncrypter.Result result = runBackup(BACKUP_SIZE); + List<EncryptedChunk> chunks = result.getNewChunks(); + + // Last chunk could be smaller, depending on the file size and how it is chunked + for (EncryptedChunk chunk : chunks.subList(0, chunks.size() - 2)) { + assertThat(chunk.encryptedBytes().length) + .isAtLeast(MIN_CHUNK_SIZE + BYTES_OVERHEAD_PER_CHUNK); + } + } + + @Test + public void testBackup_producesNoChunksLargerThanMaxSize() throws Exception { + BackupEncrypter.Result result = runBackup(BACKUP_SIZE); + List<EncryptedChunk> chunks = result.getNewChunks(); + + for (EncryptedChunk chunk : chunks) { + assertThat(chunk.encryptedBytes().length) + .isAtMost(MAX_CHUNK_SIZE + BYTES_OVERHEAD_PER_CHUNK); + } + } + + @Test + public void testBackup_producesAFileOfTheExpectedSize() throws Exception { + BackupEncrypter.Result result = runBackup(BACKUP_SIZE); + HashMap<ChunkHash, EncryptedChunk> chunksBySha256 = + chunksIndexedByKey(result.getNewChunks()); + + int expectedSize = BACKUP_SIZE + result.getAllChunks().size() * BYTES_OVERHEAD_PER_CHUNK; + int size = 0; + for (ChunkHash byteString : result.getAllChunks()) { + size += chunksBySha256.get(byteString).encryptedBytes().length; + } + assertThat(size).isEqualTo(expectedSize); + } + + @Test + public void testBackup_forSameFile_producesNoNewChunks() throws Exception { + byte[] backupData = getRandomData(BACKUP_SIZE); + BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of()); + + BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks()); + + assertThat(incrementalResult.getNewChunks()).isEmpty(); + } + + @Test + public void testBackup_onlyUpdatesChangedChunks() throws Exception { + byte[] backupData = getRandomData(BACKUP_SIZE); + BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of()); + + // Let's update the 2nd and 5th chunk + backupData[positionOfChunk(result, 1)]++; + backupData[positionOfChunk(result, 4)]++; + BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks()); + + assertThat(incrementalResult.getNewChunks()).hasSize(2); + } + + @Test + public void testBackup_doesNotIncludeUpdatedChunksInNewListing() throws Exception { + byte[] backupData = getRandomData(BACKUP_SIZE); + BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of()); + + // Let's update the 2nd and 5th chunk + backupData[positionOfChunk(result, 1)]++; + backupData[positionOfChunk(result, 4)]++; + BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks()); + + List<EncryptedChunk> newChunks = incrementalResult.getNewChunks(); + List<ChunkHash> chunkListing = result.getAllChunks(); + assertThat(newChunks).doesNotContain(chunkListing.get(1)); + assertThat(newChunks).doesNotContain(chunkListing.get(4)); + } + + @Test + public void testBackup_includesUnchangedChunksInNewListing() throws Exception { + byte[] backupData = getRandomData(BACKUP_SIZE); + BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of()); + + // Let's update the 2nd and 5th chunk + backupData[positionOfChunk(result, 1)]++; + backupData[positionOfChunk(result, 4)]++; + BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks()); + + HashSet<ChunkHash> chunksPresentInIncremental = + new HashSet<>(incrementalResult.getAllChunks()); + chunksPresentInIncremental.removeAll(result.getAllChunks()); + + assertThat(chunksPresentInIncremental).hasSize(2); + } + + @Test + public void testBackup_forSameData_createsSameDigest() throws Exception { + byte[] backupData = getRandomData(SMALL_BACKUP_SIZE); + + BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of()); + BackupEncrypter.Result result2 = runBackup(backupData, ImmutableList.of()); + assertThat(result.getDigest()).isEqualTo(result2.getDigest()); + } + + @Test + public void testBackup_forDifferentData_createsDifferentDigest() throws Exception { + byte[] backup1Data = getRandomData(SMALL_BACKUP_SIZE); + byte[] backup2Data = getRandomData(SMALL_BACKUP_SIZE); + + BackupEncrypter.Result result = runBackup(backup1Data, ImmutableList.of()); + BackupEncrypter.Result result2 = runBackup(backup2Data, ImmutableList.of()); + assertThat(result.getDigest()).isNotEqualTo(result2.getDigest()); + } + + @Test + public void testBackup_createsDigestOf32Bytes() throws Exception { + assertThat(runBackup(getRandomData(SMALL_BACKUP_SIZE), ImmutableList.of()).getDigest()) + .hasLength(MESSAGE_DIGEST_SIZE_IN_BYTES); + } + + private byte[] getRandomData(int size) throws Exception { + RandomInputStream randomInputStream = new RandomInputStream(mRandom, size); + byte[] backupData = new byte[size]; + randomInputStream.read(backupData); + return backupData; + } + + private BackupEncrypter.Result runBackup(int backupSize) throws Exception { + RandomInputStream dataStream = new RandomInputStream(mRandom, backupSize); + BackupStreamEncrypter task = + new BackupStreamEncrypter( + dataStream, MIN_CHUNK_SIZE, MAX_CHUNK_SIZE, AVERAGE_CHUNK_SIZE); + return task.backup(mSecretKey, mSalt, ImmutableSet.of()); + } + + private BackupEncrypter.Result runBackup(byte[] data, List<ChunkHash> existingChunks) + throws Exception { + ByteArrayInputStream dataStream = new ByteArrayInputStream(data); + BackupStreamEncrypter task = + new BackupStreamEncrypter( + dataStream, MIN_CHUNK_SIZE, MAX_CHUNK_SIZE, AVERAGE_CHUNK_SIZE); + return task.backup(mSecretKey, mSalt, ImmutableSet.copyOf(existingChunks)); + } + + /** Returns a {@link HashMap} of the chunks, indexed by the SHA-256 Mac key. */ + private static HashMap<ChunkHash, EncryptedChunk> chunksIndexedByKey( + List<EncryptedChunk> chunks) { + HashMap<ChunkHash, EncryptedChunk> chunksByKey = new HashMap<>(); + for (EncryptedChunk chunk : chunks) { + chunksByKey.put(chunk.key(), chunk); + } + return chunksByKey; + } + + /** + * Returns the start position of the chunk in the plaintext backup data. + * + * @param result The result from a backup. + * @param index The index of the chunk in question. + * @return the start position. + */ + private static int positionOfChunk(BackupEncrypter.Result result, int index) { + HashMap<ChunkHash, EncryptedChunk> byKey = chunksIndexedByKey(result.getNewChunks()); + List<ChunkHash> listing = result.getAllChunks(); + + int position = 0; + for (int i = 0; i < index - 1; i++) { + EncryptedChunk chunk = byKey.get(listing.get(i)); + position += chunk.encryptedBytes().length - BYTES_OVERHEAD_PER_CHUNK; + } + + return position; + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/testing/RandomInputStream.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/testing/RandomInputStream.java new file mode 100644 index 000000000000..998da0bf9696 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/testing/RandomInputStream.java @@ -0,0 +1,78 @@ +/* + * 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.testing; + +import static com.android.internal.util.Preconditions.checkArgument; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Random; + +/** {@link InputStream} that generates random bytes up to a given length. For testing purposes. */ +public class RandomInputStream extends InputStream { + private static final int BYTE_MAX_VALUE = 255; + + private final Random mRandom; + private final int mSizeBytes; + private int mBytesRead; + + /** + * A new instance, generating {@code sizeBytes} from {@code random} as a source. + * + * @param random Source of random bytes. + * @param sizeBytes The number of bytes to generate before closing the stream. + */ + public RandomInputStream(Random random, int sizeBytes) { + mRandom = random; + mSizeBytes = sizeBytes; + mBytesRead = 0; + } + + @Override + public int read() throws IOException { + if (isFinished()) { + return -1; + } + mBytesRead++; + return mRandom.nextInt(BYTE_MAX_VALUE); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + checkArgument(off + len <= b.length); + if (isFinished()) { + return -1; + } + int length = Math.min(len, mSizeBytes - mBytesRead); + int end = off + length; + + for (int i = off; i < end; ) { + for (int rnd = mRandom.nextInt(), n = Math.min(end - i, Integer.SIZE / Byte.SIZE); + n-- > 0; + rnd >>= Byte.SIZE) { + b[i++] = (byte) rnd; + } + } + + mBytesRead += length; + return length; + } + + private boolean isFinished() { + return mBytesRead >= mSizeBytes; + } +} 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 new file mode 100644 index 000000000000..3f3494d2c22c --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2018 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.testing; + +import java.security.NoSuchAlgorithmException; +import java.util.Random; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +/** Helpers for crypto code tests. */ +public class CryptoTestUtils { + private static final String KEY_ALGORITHM = "AES"; + private static final int KEY_SIZE_BITS = 256; + + private CryptoTestUtils() {} + + public static SecretKey generateAesKey() throws NoSuchAlgorithmException { + KeyGenerator keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM); + keyGenerator.init(KEY_SIZE_BITS); + return keyGenerator.generateKey(); + } + + /** Generates a byte array of size {@code n} containing random bytes. */ + public static byte[] generateRandomBytes(int n) { + byte[] bytes = new byte[n]; + Random random = new Random(); + random.nextBytes(bytes); + return bytes; + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowInternalRecoveryServiceException.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowInternalRecoveryServiceException.java new file mode 100644 index 000000000000..9c06d81ce550 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowInternalRecoveryServiceException.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2018 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.testing.shadows; + +import android.security.keystore.recovery.InternalRecoveryServiceException; + +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; + +/** Shadow {@link InternalRecoveryServiceException}. */ +@Implements(InternalRecoveryServiceException.class) +public class ShadowInternalRecoveryServiceException { + private String mMessage; + + @Implementation + public void __constructor__(String message) { + mMessage = message; + } + + @Implementation + public void __constructor__(String message, Throwable cause) { + mMessage = message; + } + + @Implementation + public String getMessage() { + return mMessage; + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowRecoveryController.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowRecoveryController.java new file mode 100644 index 000000000000..7dad8a4e3ff3 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowRecoveryController.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2018 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.testing.shadows; + +import android.content.Context; +import android.security.keystore.recovery.InternalRecoveryServiceException; +import android.security.keystore.recovery.LockScreenRequiredException; +import android.security.keystore.recovery.RecoveryController; + +import com.google.common.collect.ImmutableList; + +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.Resetter; + +import java.lang.reflect.Constructor; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.util.HashMap; +import java.util.List; + +import javax.crypto.KeyGenerator; + +/** + * Shadow of {@link RecoveryController}. + * + * <p>Instead of generating keys via the {@link RecoveryController}, this shadow generates them in + * memory. + */ +@Implements(RecoveryController.class) +public class ShadowRecoveryController { + private static final String KEY_GENERATOR_ALGORITHM = "AES"; + private static final int KEY_SIZE_BITS = 256; + + private static boolean sIsSupported = true; + private static boolean sThrowsInternalError = false; + private static HashMap<String, Key> sKeysByAlias = new HashMap<>(); + private static HashMap<String, Integer> sKeyStatusesByAlias = new HashMap<>(); + + @Implementation + public void __constructor__() { + // do not throw + } + + @Implementation + public static RecoveryController getInstance(Context context) { + // Call non-public constructor. + try { + Constructor<RecoveryController> constructor = RecoveryController.class.getConstructor(); + return constructor.newInstance(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Implementation + public static boolean isRecoverableKeyStoreEnabled(Context context) { + return sIsSupported; + } + + @Implementation + public Key generateKey(String alias) + throws InternalRecoveryServiceException, LockScreenRequiredException { + maybeThrowError(); + KeyGenerator keyGenerator; + try { + keyGenerator = KeyGenerator.getInstance(KEY_GENERATOR_ALGORITHM); + } catch (NoSuchAlgorithmException e) { + // Should never happen + throw new RuntimeException(e); + } + + keyGenerator.init(KEY_SIZE_BITS); + Key key = keyGenerator.generateKey(); + sKeysByAlias.put(alias, key); + sKeyStatusesByAlias.put(alias, RecoveryController.RECOVERY_STATUS_SYNC_IN_PROGRESS); + return key; + } + + @Implementation + public Key getKey(String alias) + throws InternalRecoveryServiceException, UnrecoverableKeyException { + return sKeysByAlias.get(alias); + } + + @Implementation + public void removeKey(String alias) throws InternalRecoveryServiceException { + sKeyStatusesByAlias.remove(alias); + sKeysByAlias.remove(alias); + } + + @Implementation + public int getRecoveryStatus(String alias) throws InternalRecoveryServiceException { + maybeThrowError(); + return sKeyStatusesByAlias.getOrDefault( + alias, RecoveryController.RECOVERY_STATUS_PERMANENT_FAILURE); + } + + @Implementation + public List<String> getAliases() throws InternalRecoveryServiceException { + return ImmutableList.copyOf(sKeyStatusesByAlias.keySet()); + } + + private static void maybeThrowError() throws InternalRecoveryServiceException { + if (sThrowsInternalError) { + throw new InternalRecoveryServiceException("test error"); + } + } + + /** Sets the recovery status of the key with {@code alias} to {@code status}. */ + public static void setRecoveryStatus(String alias, int status) { + sKeyStatusesByAlias.put(alias, status); + } + + /** Sets all existing keys to being synced. */ + public static void syncAllKeys() { + for (String alias : sKeysByAlias.keySet()) { + sKeyStatusesByAlias.put(alias, RecoveryController.RECOVERY_STATUS_SYNCED); + } + } + + public static void setThrowsInternalError(boolean throwsInternalError) { + ShadowRecoveryController.sThrowsInternalError = throwsInternalError; + } + + public static void setIsSupported(boolean isSupported) { + ShadowRecoveryController.sIsSupported = isSupported; + } + + @Resetter + public static void reset() { + sIsSupported = true; + sThrowsInternalError = false; + sKeysByAlias.clear(); + sKeyStatusesByAlias.clear(); + } +} |