diff options
Diffstat (limited to 'packages/BackupEncryption')
13 files changed, 1212 insertions, 1 deletions
diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/FullBackupDataProcessor.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/FullBackupDataProcessor.java new file mode 100644 index 000000000000..f3ab2bde086a --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/FullBackupDataProcessor.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; + +import android.app.backup.BackupTransport; + +import java.io.IOException; +import java.io.InputStream; + +/** Accepts the full backup data stream and sends it to the server. */ +public interface FullBackupDataProcessor { + /** + * Prepares the upload. + * + * <p>After this, call {@link #start()} to establish the connection. + * + * @param inputStream to read the backup data from, calling {@link #finish} or {@link #cancel} + * will close the stream + * @return {@code true} if the connection was set up successfully, otherwise {@code false} + */ + boolean initiate(InputStream inputStream) throws IOException; + + /** + * Starts the upload, establishing the connection to the server. + * + * <p>After this, call {@link #pushData(int)} to request that the processor reads data from the + * socket, and uploads it to the server. + * + * <p>After this you must call one of {@link #cancel()}, {@link #finish()}, {@link + * #handleCheckSizeRejectionZeroBytes()}, {@link #handleCheckSizeRejectionQuotaExceeded()} or + * {@link #handleSendBytesQuotaExceeded()} to close the upload. + */ + void start(); + + /** + * Requests that the processor read {@code numBytes} from the input stream passed in {@link + * #initiate(InputStream)} and upload them to the server. + * + * @return {@link BackupTransport#TRANSPORT_OK} if the upload succeeds, or {@link + * BackupTransport#TRANSPORT_QUOTA_EXCEEDED} if the upload exceeded the server-side app size + * quota, or {@link BackupTransport#TRANSPORT_PACKAGE_REJECTED} for other errors. + */ + int pushData(int numBytes); + + /** Cancels the upload and tears down the connection. */ + void cancel(); + + /** + * Finish the upload and tear down the connection. + * + * <p>Call this after there is no more data to push with {@link #pushData(int)}. + * + * @return One of {@link BackupTransport#TRANSPORT_OK} if the app upload succeeds, {@link + * BackupTransport#TRANSPORT_QUOTA_EXCEEDED} if the upload exceeded the server-side app size + * quota, {@link BackupTransport#TRANSPORT_ERROR} for server 500s, or {@link + * BackupTransport#TRANSPORT_PACKAGE_REJECTED} for other errors. + */ + int finish(); + + /** + * Notifies the processor that the current upload should be terminated because the estimated + * size is zero. + */ + void handleCheckSizeRejectionZeroBytes(); + + /** + * Notifies the processor that the current upload should be terminated because the estimated + * size exceeds the quota. + */ + void handleCheckSizeRejectionQuotaExceeded(); + + /** + * Notifies this class that the current upload should be terminated because the quota was + * exceeded during upload. + */ + void handleSendBytesQuotaExceeded(); + + /** + * Attaches {@link FullBackupCallbacks} which the processor will notify when the backup + * succeeds. + */ + void attachCallbacks(FullBackupCallbacks fullBackupCallbacks); + + /** + * Implemented by the caller of the processor to receive notification of when the backup + * succeeds. + */ + interface FullBackupCallbacks { + /** The processor calls this to indicate that the current backup has succeeded. */ + void onSuccess(); + + /** The processor calls this if the upload failed for a non-transient reason. */ + void onTransferFailed(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/FullRestoreDataProcessor.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/FullRestoreDataProcessor.java new file mode 100644 index 000000000000..e4c40491bc2f --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/FullRestoreDataProcessor.java @@ -0,0 +1,51 @@ +/* + * 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 java.io.IOException; + +/** + * Retrieves the data during a full restore, decrypting it if necessary. + * + * <p>Use {@link FullRestoreDataProcessorFactory} to construct the encrypted or unencrypted + * processor as appropriate during restore. + */ +public interface FullRestoreDataProcessor { + /** Return value of {@link #readNextChunk} when there is no more data to download. */ + int END_OF_STREAM = -1; + + /** + * Reads the next chunk of restore data and writes it to the given buffer. + * + * <p>Where necessary, will open the connection to the server and/or decrypt the backup file. + * + * <p>The implementation may retry various errors. If the retries fail it will throw the + * relevant exception. + * + * @return the number of bytes read, or {@link #END_OF_STREAM} if there is no more data + * @throws IOException when downloading from the network or writing to disk + */ + int readNextChunk(byte[] buffer) throws IOException; + + /** + * Closes the connection to the server, deletes any temporary files and optionally sends a log + * with the given finish type. + * + * @param finishType one of {@link FullRestoreDownloader.FinishType} + */ + void finish(FullRestoreDownloader.FinishType finishType); +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/StreamUtils.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/StreamUtils.java new file mode 100644 index 000000000000..91b292666756 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/StreamUtils.java @@ -0,0 +1,36 @@ +/* + * 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 java.io.Closeable; +import java.io.IOException; + +/** Utility methods for dealing with Streams */ +public class StreamUtils { + /** + * Close a Closeable and silently ignore any IOExceptions. + * + * @param closeable The closeable to close + */ + public static void closeQuietly(Closeable closeable) { + try { + closeable.close(); + } catch (IOException ioe) { + // Silently ignore + } + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedFullBackupTask.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedFullBackupTask.java new file mode 100644 index 000000000000..a938d715a307 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedFullBackupTask.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.tasks; + +import android.content.Context; +import android.util.Slog; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.backup.encryption.StreamUtils; +import com.android.server.backup.encryption.chunking.ProtoStore; +import com.android.server.backup.encryption.chunking.cdc.FingerprintMixer; +import com.android.server.backup.encryption.client.CryptoBackupServer; +import com.android.server.backup.encryption.keys.RecoverableKeyStoreSecondaryKey; +import com.android.server.backup.encryption.keys.TertiaryKeyManager; +import com.android.server.backup.encryption.keys.TertiaryKeyRotationScheduler; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto.ChunkListing; +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; + +import java.io.IOException; +import java.io.InputStream; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.Callable; + +import javax.crypto.SecretKey; + +/** + * Task which reads a stream of plaintext full backup data, chunks it, encrypts it and uploads it to + * the server. + * + * <p>Once the backup completes or fails, closes the input stream. + */ +public class EncryptedFullBackupTask implements Callable<Void> { + private static final String TAG = "EncryptedFullBackupTask"; + + private static final int MIN_CHUNK_SIZE_BYTES = 2 * 1024; + private static final int MAX_CHUNK_SIZE_BYTES = 64 * 1024; + private static final int AVERAGE_CHUNK_SIZE_BYTES = 4 * 1024; + + // TODO(b/69350270): Remove this hard-coded salt and related logic once we feel confident that + // incremental backup has happened at least once for all existing packages/users since we moved + // to + // using a randomly generated salt. + // + // The hard-coded fingerprint mixer salt was used for a short time period before replaced by one + // that is randomly generated on initial non-incremental backup and stored in ChunkListing to be + // reused for succeeding incremental backups. If an old ChunkListing does not have a + // fingerprint_mixer_salt, we assume that it was last backed up before a randomly generated salt + // is used so we use the hardcoded salt and set ChunkListing#fingerprint_mixer_salt to this + // value. + // Eventually all backup ChunkListings will have this field set and then we can remove the + // default + // value in the code. + static final byte[] DEFAULT_FINGERPRINT_MIXER_SALT = + Arrays.copyOf(new byte[] {20, 23}, FingerprintMixer.SALT_LENGTH_BYTES); + + private final ProtoStore<ChunkListing> mChunkListingStore; + private final TertiaryKeyManager mTertiaryKeyManager; + private final InputStream mInputStream; + private final EncryptedBackupTask mTask; + private final String mPackageName; + private final SecureRandom mSecureRandom; + + /** Creates a new instance with the default min, max and average chunk sizes. */ + public static EncryptedFullBackupTask newInstance( + Context context, + CryptoBackupServer cryptoBackupServer, + SecureRandom secureRandom, + RecoverableKeyStoreSecondaryKey secondaryKey, + String packageName, + InputStream inputStream) + throws IOException { + EncryptedBackupTask encryptedBackupTask = + new EncryptedBackupTask( + cryptoBackupServer, + secureRandom, + packageName, + new BackupStreamEncrypter( + inputStream, + MIN_CHUNK_SIZE_BYTES, + MAX_CHUNK_SIZE_BYTES, + AVERAGE_CHUNK_SIZE_BYTES)); + TertiaryKeyManager tertiaryKeyManager = + new TertiaryKeyManager( + context, + secureRandom, + TertiaryKeyRotationScheduler.getInstance(context), + secondaryKey, + packageName); + + return new EncryptedFullBackupTask( + ProtoStore.createChunkListingStore(context), + tertiaryKeyManager, + encryptedBackupTask, + inputStream, + packageName, + new SecureRandom()); + } + + @VisibleForTesting + EncryptedFullBackupTask( + ProtoStore<ChunkListing> chunkListingStore, + TertiaryKeyManager tertiaryKeyManager, + EncryptedBackupTask task, + InputStream inputStream, + String packageName, + SecureRandom secureRandom) { + mChunkListingStore = chunkListingStore; + mTertiaryKeyManager = tertiaryKeyManager; + mInputStream = inputStream; + mTask = task; + mPackageName = packageName; + mSecureRandom = secureRandom; + } + + @Override + public Void call() throws Exception { + try { + Optional<ChunkListing> maybeOldChunkListing = + mChunkListingStore.loadProto(mPackageName); + + if (maybeOldChunkListing.isPresent()) { + Slog.i(TAG, "Found previous chunk listing for " + mPackageName); + } + + // If the key has been rotated then we must re-encrypt all of the backup data. + if (mTertiaryKeyManager.wasKeyRotated()) { + Slog.i( + TAG, + "Key was rotated or newly generated for " + + mPackageName + + ", so performing a full backup."); + maybeOldChunkListing = Optional.empty(); + mChunkListingStore.deleteProto(mPackageName); + } + + SecretKey tertiaryKey = mTertiaryKeyManager.getKey(); + WrappedKeyProto.WrappedKey wrappedTertiaryKey = mTertiaryKeyManager.getWrappedKey(); + + ChunkListing newChunkListing; + if (!maybeOldChunkListing.isPresent()) { + byte[] fingerprintMixerSalt = new byte[FingerprintMixer.SALT_LENGTH_BYTES]; + mSecureRandom.nextBytes(fingerprintMixerSalt); + newChunkListing = + mTask.performNonIncrementalBackup( + tertiaryKey, wrappedTertiaryKey, fingerprintMixerSalt); + } else { + ChunkListing oldChunkListing = maybeOldChunkListing.get(); + + if (oldChunkListing.fingerprintMixerSalt == null + || oldChunkListing.fingerprintMixerSalt.length == 0) { + oldChunkListing.fingerprintMixerSalt = DEFAULT_FINGERPRINT_MIXER_SALT; + } + + newChunkListing = + mTask.performIncrementalBackup( + tertiaryKey, wrappedTertiaryKey, oldChunkListing); + } + + mChunkListingStore.saveProto(mPackageName, newChunkListing); + Slog.v(TAG, "Saved chunk listing for " + mPackageName); + } catch (IOException e) { + Slog.e(TAG, "Storage exception, wiping state"); + mChunkListingStore.deleteProto(mPackageName); + throw e; + } finally { + StreamUtils.closeQuietly(mInputStream); + } + + return null; + } + + /** + * Signals to the task that the backup has been cancelled. If the upload has not yet started + * then the task will not upload any data to the server or save the new chunk listing. + * + * <p>You must then terminate the input stream. + */ + public void cancel() { + mTask.cancel(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedFullRestoreTask.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedFullRestoreTask.java new file mode 100644 index 000000000000..04381af561b2 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/EncryptedFullRestoreTask.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.backup.encryption.tasks; + +import static com.android.internal.util.Preconditions.checkArgument; + +import android.annotation.Nullable; +import android.content.Context; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.backup.encryption.FullRestoreDataProcessor; +import com.android.server.backup.encryption.FullRestoreDownloader; +import com.android.server.backup.encryption.StreamUtils; +import com.android.server.backup.encryption.chunking.DecryptedChunkFileOutput; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.ShortBufferException; + +/** Downloads the encrypted backup file, decrypts it and passes the data to backup manager. */ +public class EncryptedFullRestoreTask implements FullRestoreDataProcessor { + private static final String DEFAULT_TEMPORARY_FOLDER = "encrypted_restore_temp"; + private static final String ENCRYPTED_FILE_NAME = "encrypted_restore"; + private static final String DECRYPTED_FILE_NAME = "decrypted_restore"; + + private final FullRestoreToFileTask mFullRestoreToFileTask; + private final BackupFileDecryptorTask mBackupFileDecryptorTask; + private final File mEncryptedFile; + private final File mDecryptedFile; + @Nullable private InputStream mDecryptedFileInputStream; + + /** + * Creates a new task which stores temporary files in the files directory. + * + * @param fullRestoreDownloader which will download the backup file + * @param tertiaryKey which the backup file is encrypted with + */ + public static EncryptedFullRestoreTask newInstance( + Context context, FullRestoreDownloader fullRestoreDownloader, SecretKey tertiaryKey) + throws NoSuchAlgorithmException, NoSuchPaddingException { + File temporaryFolder = new File(context.getFilesDir(), DEFAULT_TEMPORARY_FOLDER); + temporaryFolder.mkdirs(); + return new EncryptedFullRestoreTask( + temporaryFolder, fullRestoreDownloader, new BackupFileDecryptorTask(tertiaryKey)); + } + + @VisibleForTesting + EncryptedFullRestoreTask( + File temporaryFolder, + FullRestoreDownloader fullRestoreDownloader, + BackupFileDecryptorTask backupFileDecryptorTask) { + checkArgument(temporaryFolder.isDirectory(), "Temporary folder must be existing directory"); + + mEncryptedFile = new File(temporaryFolder, ENCRYPTED_FILE_NAME); + mDecryptedFile = new File(temporaryFolder, DECRYPTED_FILE_NAME); + + mFullRestoreToFileTask = new FullRestoreToFileTask(fullRestoreDownloader); + mBackupFileDecryptorTask = backupFileDecryptorTask; + } + + /** + * Reads the next decrypted bytes into the given buffer. + * + * <p>During the first call this method will download the backup file from the server, decrypt + * it and save it to disk. It will then read the bytes from the file on disk. + * + * <p>Once this method has read all the bytes of the file, the caller must call {@link #finish} + * to clean up. + * + * @return the number of bytes read, or {@code -1} on reaching the end of the file + */ + @Override + public int readNextChunk(byte[] buffer) throws IOException { + if (mDecryptedFileInputStream == null) { + try { + mDecryptedFileInputStream = downloadAndDecryptBackup(); + } catch (BadPaddingException + | InvalidKeyException + | NoSuchAlgorithmException + | IllegalBlockSizeException + | ShortBufferException + | EncryptedRestoreException + | InvalidAlgorithmParameterException e) { + throw new IOException("Encryption issue", e); + } + } + + return mDecryptedFileInputStream.read(buffer); + } + + private InputStream downloadAndDecryptBackup() + throws IOException, BadPaddingException, InvalidKeyException, NoSuchAlgorithmException, + IllegalBlockSizeException, ShortBufferException, EncryptedRestoreException, + InvalidAlgorithmParameterException { + mFullRestoreToFileTask.restoreToFile(mEncryptedFile); + mBackupFileDecryptorTask.decryptFile( + mEncryptedFile, new DecryptedChunkFileOutput(mDecryptedFile)); + mEncryptedFile.delete(); + return new BufferedInputStream(new FileInputStream(mDecryptedFile)); + } + + /** Cleans up temporary files. */ + @Override + public void finish(FullRestoreDownloader.FinishType unusedFinishType) { + // The download is finished and log sent during RestoreToFileTask#restoreToFile(), so we + // don't need to do either of those things here. + + StreamUtils.closeQuietly(mDecryptedFileInputStream); + mEncryptedFile.delete(); + mDecryptedFile.delete(); + } +} diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/SizeQuotaExceededException.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/SizeQuotaExceededException.java new file mode 100644 index 000000000000..515db86b6687 --- /dev/null +++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/tasks/SizeQuotaExceededException.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.backup.encryption.tasks; + +/** Exception thrown when aa backup has exceeded the space allowed for that user */ +public class SizeQuotaExceededException extends RuntimeException { + public SizeQuotaExceededException() { + super("Backup size quota exceeded."); + } +} diff --git a/packages/BackupEncryption/test/robolectric-integration/Android.bp b/packages/BackupEncryption/test/robolectric-integration/Android.bp new file mode 100644 index 000000000000..f696278ab967 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric-integration/Android.bp @@ -0,0 +1,33 @@ +// 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_robolectric_test { + name: "BackupEncryptionRoboIntegTests", + srcs: [ + "src/**/*.java", + ], + 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", +} diff --git a/packages/BackupEncryption/test/robolectric-integration/AndroidManifest.xml b/packages/BackupEncryption/test/robolectric-integration/AndroidManifest.xml new file mode 100644 index 000000000000..c3930cc7c4f1 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric-integration/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.robointeg"> + + <application/> + +</manifest> diff --git a/packages/BackupEncryption/test/robolectric-integration/config/robolectric.properties b/packages/BackupEncryption/test/robolectric-integration/config/robolectric.properties new file mode 100644 index 000000000000..26fceb3f84a4 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric-integration/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-integration/src/com/android/server/backup/encryption/RoundTripTest.java b/packages/BackupEncryption/test/robolectric-integration/src/com/android/server/backup/encryption/RoundTripTest.java new file mode 100644 index 000000000000..8ec68fdf822d --- /dev/null +++ b/packages/BackupEncryption/test/robolectric-integration/src/com/android/server/backup/encryption/RoundTripTest.java @@ -0,0 +1,217 @@ +/* + * 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 android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.server.backup.encryption.client.CryptoBackupServer; +import com.android.server.backup.encryption.keys.RecoverableKeyStoreSecondaryKey; +import com.android.server.backup.encryption.keys.TertiaryKeyManager; +import com.android.server.backup.encryption.keys.TertiaryKeyRotationScheduler; +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto; +import com.android.server.backup.encryption.tasks.EncryptedFullBackupTask; +import com.android.server.backup.encryption.tasks.EncryptedFullRestoreTask; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Map; + +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; + +@RunWith(RobolectricTestRunner.class) +public class RoundTripTest { + /** Amount of data we want to round trip in this test */ + private static final int TEST_DATA_SIZE = 1024 * 1024; // 1MB + + /** Buffer size used when reading data from the restore task */ + private static final int READ_BUFFER_SIZE = 1024; // 1024 byte buffer. + + /** Key parameters used for the secondary encryption key */ + private static final String KEY_ALGORITHM = "AES"; + private static final int KEY_SIZE_BITS = 256; + + /** Package name for our test package */ + private static final String TEST_PACKAGE_NAME = "com.android.backup.test"; + + /** The name we use to refer to our secondary key */ + private static final String TEST_KEY_ALIAS = "test/backup/KEY_ALIAS"; + + /** Original data used for comparison after round trip */ + private final byte[] mOriginalData = new byte[TEST_DATA_SIZE]; + + /** App context, used to store the key data and chunk listings */ + private Context mContext; + + /** The secondary key we're using for the test */ + private RecoverableKeyStoreSecondaryKey mSecondaryKey; + + /** Source of random material which is considered non-predictable in its' generation */ + private SecureRandom mSecureRandom = new SecureRandom(); + + @Before + public void setUp() throws NoSuchAlgorithmException { + mContext = ApplicationProvider.getApplicationContext(); + mSecondaryKey = new RecoverableKeyStoreSecondaryKey(TEST_KEY_ALIAS, generateAesKey()); + fillBuffer(mOriginalData); + } + + @Test + public void testRoundTrip() throws Exception { + byte[] backupData = performBackup(mOriginalData); + assertThat(backupData).isNotEqualTo(mOriginalData); + byte[] restoredData = performRestore(backupData); + assertThat(restoredData).isEqualTo(mOriginalData); + } + + /** Perform a backup and return the backed-up representation of the data */ + private byte[] performBackup(byte[] backupData) throws Exception { + DummyServer dummyServer = new DummyServer(); + EncryptedFullBackupTask backupTask = + EncryptedFullBackupTask.newInstance( + mContext, + dummyServer, + mSecureRandom, + mSecondaryKey, + TEST_PACKAGE_NAME, + new ByteArrayInputStream(backupData)); + backupTask.call(); + return dummyServer.mStoredData; + } + + /** Perform a restore and resturn the bytes obtained from the restore process */ + private byte[] performRestore(byte[] backupData) + throws IOException, NoSuchAlgorithmException, NoSuchPaddingException, + InvalidAlgorithmParameterException, InvalidKeyException, + IllegalBlockSizeException { + ByteArrayOutputStream decryptedOutput = new ByteArrayOutputStream(); + + EncryptedFullRestoreTask restoreTask = + EncryptedFullRestoreTask.newInstance( + mContext, new FakeFullRestoreDownloader(backupData), getTertiaryKey()); + + byte[] buffer = new byte[READ_BUFFER_SIZE]; + int bytesRead = restoreTask.readNextChunk(buffer); + while (bytesRead != -1) { + decryptedOutput.write(buffer, 0, bytesRead); + bytesRead = restoreTask.readNextChunk(buffer); + } + + return decryptedOutput.toByteArray(); + } + + /** Get the tertiary key for our test package from the key manager */ + private SecretKey getTertiaryKey() + throws IllegalBlockSizeException, InvalidAlgorithmParameterException, + NoSuchAlgorithmException, IOException, NoSuchPaddingException, + InvalidKeyException { + TertiaryKeyManager tertiaryKeyManager = + new TertiaryKeyManager( + mContext, + mSecureRandom, + TertiaryKeyRotationScheduler.getInstance(mContext), + mSecondaryKey, + TEST_PACKAGE_NAME); + return tertiaryKeyManager.getKey(); + } + + /** Fill a buffer with data in a predictable way */ + private void fillBuffer(byte[] buffer) { + byte loopingCounter = 0; + for (int i = 0; i < buffer.length; i++) { + buffer[i] = loopingCounter; + loopingCounter++; + } + } + + /** Generate a new, random, AES key */ + public static SecretKey generateAesKey() throws NoSuchAlgorithmException { + KeyGenerator keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM); + keyGenerator.init(KEY_SIZE_BITS); + return keyGenerator.generateKey(); + } + + /** + * Dummy backup data endpoint. This stores the data so we can use it + * in subsequent test steps. + */ + private static class DummyServer implements CryptoBackupServer { + private static final String DUMMY_DOC_ID = "DummyDoc"; + + byte[] mStoredData = null; + + @Override + public String uploadIncrementalBackup( + String packageName, + String oldDocId, + byte[] diffScript, + WrappedKeyProto.WrappedKey tertiaryKey) { + throw new RuntimeException("Not Implemented"); + } + + @Override + public String uploadNonIncrementalBackup( + String packageName, byte[] data, WrappedKeyProto.WrappedKey tertiaryKey) { + assertThat(packageName).isEqualTo(TEST_PACKAGE_NAME); + mStoredData = data; + return DUMMY_DOC_ID; + } + + @Override + public void setActiveSecondaryKeyAlias( + String keyAlias, Map<String, WrappedKeyProto.WrappedKey> tertiaryKeys) { + throw new RuntimeException("Not Implemented"); + } + } + + /** Fake package wrapper which returns data from a byte array. */ + private static class FakeFullRestoreDownloader extends FullRestoreDownloader { + private final ByteArrayInputStream mData; + + FakeFullRestoreDownloader(byte[] data) { + // We override all methods of the superclass, so it does not require any collaborators. + super(); + mData = new ByteArrayInputStream(data); + } + + @Override + public int readNextChunk(byte[] buffer) throws IOException { + return mData.read(buffer); + } + + @Override + public void finish(FinishType finishType) { + // Do nothing. + } + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/EncryptedFullBackupTaskTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/EncryptedFullBackupTaskTest.java new file mode 100644 index 000000000000..096b2da10c98 --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/EncryptedFullBackupTaskTest.java @@ -0,0 +1,234 @@ +/* + * 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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertThrows; + +import com.android.server.backup.encryption.chunk.ChunkHash; +import com.android.server.backup.encryption.chunking.ProtoStore; +import com.android.server.backup.encryption.chunking.cdc.FingerprintMixer; +import com.android.server.backup.encryption.keys.TertiaryKeyManager; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto; +import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto.ChunkListing; +import com.android.server.backup.encryption.protos.nano.WrappedKeyProto.WrappedKey; +import com.android.server.backup.testing.CryptoTestUtils; + +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 org.robolectric.annotation.Config; + +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Optional; + +import javax.crypto.SecretKey; + +@Config(shadows = {EncryptedBackupTaskTest.ShadowBackupFileBuilder.class}) +@RunWith(RobolectricTestRunner.class) +public class EncryptedFullBackupTaskTest { + private static final String TEST_PACKAGE_NAME = "com.example.package"; + private static final byte[] TEST_EXISTING_FINGERPRINT_MIXER_SALT = + Arrays.copyOf(new byte[] {11}, ChunkHash.HASH_LENGTH_BYTES); + private static final byte[] TEST_GENERATED_FINGERPRINT_MIXER_SALT = + Arrays.copyOf(new byte[] {22}, ChunkHash.HASH_LENGTH_BYTES); + private static final ChunkHash TEST_CHUNK_HASH_1 = + new ChunkHash(Arrays.copyOf(new byte[] {1}, ChunkHash.HASH_LENGTH_BYTES)); + private static final ChunkHash TEST_CHUNK_HASH_2 = + new ChunkHash(Arrays.copyOf(new byte[] {2}, ChunkHash.HASH_LENGTH_BYTES)); + private static final int TEST_CHUNK_LENGTH_1 = 20; + private static final int TEST_CHUNK_LENGTH_2 = 40; + + @Mock private ProtoStore<ChunkListing> mChunkListingStore; + @Mock private TertiaryKeyManager mTertiaryKeyManager; + @Mock private InputStream mInputStream; + @Mock private EncryptedBackupTask mEncryptedBackupTask; + @Mock private SecretKey mTertiaryKey; + @Mock private SecureRandom mSecureRandom; + + private EncryptedFullBackupTask mTask; + private ChunkListing mOldChunkListing; + private ChunkListing mNewChunkListing; + private WrappedKey mWrappedTertiaryKey; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + mWrappedTertiaryKey = new WrappedKey(); + when(mTertiaryKeyManager.getKey()).thenReturn(mTertiaryKey); + when(mTertiaryKeyManager.getWrappedKey()).thenReturn(mWrappedTertiaryKey); + + mOldChunkListing = + CryptoTestUtils.newChunkListing( + /* docId */ null, + TEST_EXISTING_FINGERPRINT_MIXER_SALT, + ChunksMetadataProto.AES_256_GCM, + ChunksMetadataProto.CHUNK_ORDERING_TYPE_UNSPECIFIED, + CryptoTestUtils.newChunk(TEST_CHUNK_HASH_1.getHash(), TEST_CHUNK_LENGTH_1)); + mNewChunkListing = + CryptoTestUtils.newChunkListing( + /* docId */ null, + /* fingerprintSalt */ null, + ChunksMetadataProto.AES_256_GCM, + ChunksMetadataProto.CHUNK_ORDERING_TYPE_UNSPECIFIED, + CryptoTestUtils.newChunk(TEST_CHUNK_HASH_1.getHash(), TEST_CHUNK_LENGTH_1), + CryptoTestUtils.newChunk(TEST_CHUNK_HASH_2.getHash(), TEST_CHUNK_LENGTH_2)); + when(mEncryptedBackupTask.performNonIncrementalBackup(any(), any(), any())) + .thenReturn(mNewChunkListing); + when(mEncryptedBackupTask.performIncrementalBackup(any(), any(), any())) + .thenReturn(mNewChunkListing); + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)).thenReturn(Optional.empty()); + + doAnswer(invocation -> { + byte[] byteArray = (byte[]) invocation.getArguments()[0]; + System.arraycopy( + TEST_GENERATED_FINGERPRINT_MIXER_SALT, + /* srcPos */ 0, + byteArray, + /* destPos */ 0, + FingerprintMixer.SALT_LENGTH_BYTES); + return null; + }) + .when(mSecureRandom) + .nextBytes(any(byte[].class)); + + mTask = + new EncryptedFullBackupTask( + mChunkListingStore, + mTertiaryKeyManager, + mEncryptedBackupTask, + mInputStream, + TEST_PACKAGE_NAME, + mSecureRandom); + } + + @Test + public void call_existingChunkListingButTertiaryKeyRotated_performsNonIncrementalBackup() + throws Exception { + when(mTertiaryKeyManager.wasKeyRotated()).thenReturn(true); + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)) + .thenReturn(Optional.of(mOldChunkListing)); + + mTask.call(); + + verify(mEncryptedBackupTask) + .performNonIncrementalBackup( + eq(mTertiaryKey), + eq(mWrappedTertiaryKey), + eq(TEST_GENERATED_FINGERPRINT_MIXER_SALT)); + } + + @Test + public void call_noExistingChunkListing_performsNonIncrementalBackup() throws Exception { + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)).thenReturn(Optional.empty()); + mTask.call(); + verify(mEncryptedBackupTask) + .performNonIncrementalBackup( + eq(mTertiaryKey), + eq(mWrappedTertiaryKey), + eq(TEST_GENERATED_FINGERPRINT_MIXER_SALT)); + } + + @Test + public void call_existingChunkListing_performsIncrementalBackup() throws Exception { + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)) + .thenReturn(Optional.of(mOldChunkListing)); + mTask.call(); + verify(mEncryptedBackupTask) + .performIncrementalBackup( + eq(mTertiaryKey), eq(mWrappedTertiaryKey), eq(mOldChunkListing)); + } + + @Test + public void + call_existingChunkListingWithNoFingerprintMixerSalt_doesntSetSaltBeforeIncBackup() + throws Exception { + mOldChunkListing.fingerprintMixerSalt = new byte[0]; + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)) + .thenReturn(Optional.of(mOldChunkListing)); + + mTask.call(); + + verify(mEncryptedBackupTask) + .performIncrementalBackup( + eq(mTertiaryKey), eq(mWrappedTertiaryKey), eq(mOldChunkListing)); + } + + @Test + public void call_noExistingChunkListing_storesNewChunkListing() throws Exception { + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)).thenReturn(Optional.empty()); + mTask.call(); + verify(mChunkListingStore).saveProto(TEST_PACKAGE_NAME, mNewChunkListing); + } + + @Test + public void call_existingChunkListing_storesNewChunkListing() throws Exception { + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)) + .thenReturn(Optional.of(mOldChunkListing)); + mTask.call(); + verify(mChunkListingStore).saveProto(TEST_PACKAGE_NAME, mNewChunkListing); + } + + @Test + public void call_exceptionDuringBackup_doesNotSaveNewChunkListing() throws Exception { + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)).thenReturn(Optional.empty()); + when(mEncryptedBackupTask.performNonIncrementalBackup(any(), any(), any())) + .thenThrow(GeneralSecurityException.class); + + assertThrows(Exception.class, () -> mTask.call()); + + assertThat(mChunkListingStore.loadProto(TEST_PACKAGE_NAME).isPresent()).isFalse(); + } + + @Test + public void call_incrementalThrowsPermanentException_clearsState() throws Exception { + when(mChunkListingStore.loadProto(TEST_PACKAGE_NAME)) + .thenReturn(Optional.of(mOldChunkListing)); + when(mEncryptedBackupTask.performIncrementalBackup(any(), any(), any())) + .thenThrow(IOException.class); + + assertThrows(IOException.class, () -> mTask.call()); + + verify(mChunkListingStore).deleteProto(TEST_PACKAGE_NAME); + } + + @Test + public void call_closesInputStream() throws Exception { + mTask.call(); + verify(mInputStream).close(); + } + + @Test + public void cancel_cancelsTask() throws Exception { + mTask.cancel(); + verify(mEncryptedBackupTask).cancel(); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/EncryptedFullRestoreTaskTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/EncryptedFullRestoreTaskTest.java new file mode 100644 index 000000000000..0affacd114bf --- /dev/null +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/EncryptedFullRestoreTaskTest.java @@ -0,0 +1,129 @@ +/* + * 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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; + +import static java.util.stream.Collectors.toList; + +import com.android.server.backup.encryption.FullRestoreDownloader; + +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.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +@RunWith(RobolectricTestRunner.class) +public class EncryptedFullRestoreTaskTest { + private static final int TEST_BUFFER_SIZE = 10; + private static final byte[] TEST_ENCRYPTED_DATA = {1, 2, 3, 4, 5, 6}; + private static final byte[] TEST_DECRYPTED_DATA = fakeDecrypt(TEST_ENCRYPTED_DATA); + + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock private BackupFileDecryptorTask mDecryptorTask; + + private File mFolder; + private FakeFullRestoreDownloader mFullRestorePackageWrapper; + private EncryptedFullRestoreTask mTask; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + mFolder = temporaryFolder.newFolder(); + mFullRestorePackageWrapper = new FakeFullRestoreDownloader(TEST_ENCRYPTED_DATA); + + doAnswer( + invocation -> { + File source = invocation.getArgument(0); + DecryptedChunkOutput target = invocation.getArgument(1); + byte[] decrypted = fakeDecrypt(Files.toByteArray(source)); + target.open(); + target.processChunk(decrypted, decrypted.length); + target.close(); + return null; + }) + .when(mDecryptorTask) + .decryptFile(any(), any()); + + mTask = new EncryptedFullRestoreTask(mFolder, mFullRestorePackageWrapper, mDecryptorTask); + } + + @Test + public void readNextChunk_downloadsAndDecryptsBackup() throws Exception { + ByteArrayOutputStream decryptedOutput = new ByteArrayOutputStream(); + + byte[] buffer = new byte[TEST_BUFFER_SIZE]; + int bytesRead = mTask.readNextChunk(buffer); + while (bytesRead != -1) { + decryptedOutput.write(buffer, 0, bytesRead); + bytesRead = mTask.readNextChunk(buffer); + } + + assertThat(decryptedOutput.toByteArray()).isEqualTo(TEST_DECRYPTED_DATA); + } + + @Test + public void finish_deletesTemporaryFiles() throws Exception { + mTask.readNextChunk(new byte[10]); + mTask.finish(FullRestoreDownloader.FinishType.UNKNOWN_FINISH); + + assertThat(mFolder.listFiles()).isEmpty(); + } + + /** Fake package wrapper which returns data from a byte array. */ + private static class FakeFullRestoreDownloader extends FullRestoreDownloader { + private final ByteArrayInputStream mData; + + FakeFullRestoreDownloader(byte[] data) { + // We override all methods of the superclass, so it does not require any collaborators. + super(); + mData = new ByteArrayInputStream(data); + } + + @Override + public int readNextChunk(byte[] buffer) throws IOException { + return mData.read(buffer); + } + + @Override + public void finish(FinishType finishType) { + // Nothing to do. + } + } + + /** Fake decrypts a byte array by subtracting 1 from each byte. */ + private static byte[] fakeDecrypt(byte[] input) { + return Bytes.toArray(Bytes.asList(input).stream().map(b -> b + 1).collect(toList())); + } +} diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java index 5cff53f817d4..5dfd5ee8ad53 100644 --- a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java +++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java @@ -78,7 +78,10 @@ public class CryptoTestUtils { int orderingType, ChunksMetadataProto.Chunk... chunks) { ChunksMetadataProto.ChunkListing chunkListing = new ChunksMetadataProto.ChunkListing(); - chunkListing.fingerprintMixerSalt = Arrays.copyOf(fingerprintSalt, fingerprintSalt.length); + chunkListing.fingerprintMixerSalt = + fingerprintSalt == null + ? null + : Arrays.copyOf(fingerprintSalt, fingerprintSalt.length); chunkListing.cipherType = cipherType; chunkListing.chunkOrderingType = orderingType; chunkListing.chunks = chunks; |