Move TertiaryKeyRotationScheduler and TertiaryKeyRotationWindowedCount

Migrate these classes into the framework to support encryption in the
framework for backups. There's a modification to the RotationTracker
class to re-introduce a test supporting constructor.

The modification to the blueprint is needed to provide Truthy which
is used in the tests of the migrated classes.

Bug: 111386661
Test: make RunBackupEncryptionRoboTests
Change-Id: Ibe96a9a52f638d5e87e1be46af5803672288f482
This commit is contained in:
Al Sutton
2019-08-28 11:33:41 +01:00
parent 4a4282f20c
commit bb45da74cd
5 changed files with 588 additions and 7 deletions

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018 The Android Open Source Project
* 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.
@@ -16,6 +16,8 @@
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;
@@ -46,15 +48,27 @@ public class TertiaryKeyRotationTracker {
*/
public static TertiaryKeyRotationTracker getInstance(Context context) {
return new TertiaryKeyRotationTracker(
context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE));
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 mSharedPreferences}. */
/**
* New instance, storing data in {@code sharedPreferences} and initializing backup countdown to
* {@code maxBackupsTillRotation}.
*/
@VisibleForTesting
TertiaryKeyRotationTracker(SharedPreferences sharedPreferences) {
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;
}
/**
@@ -63,7 +77,7 @@ public class TertiaryKeyRotationTracker {
* @param packageName The package name of the app.
*/
public boolean isKeyRotationDue(String packageName) {
return getBackupsSinceRotation(packageName) >= MAX_BACKUPS_UNTIL_TERTIARY_KEY_ROTATION;
return getBackupsSinceRotation(packageName) >= mMaxBackupsTillRotation;
}
/**
@@ -84,7 +98,7 @@ public class TertiaryKeyRotationTracker {
packageName,
Math.max(
0,
MAX_BACKUPS_UNTIL_TERTIARY_KEY_ROTATION
mMaxBackupsTillRotation
- backupsSinceRotation)));
}
}
@@ -102,7 +116,7 @@ public class TertiaryKeyRotationTracker {
public void markAllForRotation() {
SharedPreferences.Editor editor = mSharedPreferences.edit();
for (String packageName : mSharedPreferences.getAll().keySet()) {
editor.putInt(packageName, MAX_BACKUPS_UNTIL_TERTIARY_KEY_ROTATION);
editor.putInt(packageName, mMaxBackupsTillRotation);
}
editor.apply();
}

View File

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

View File

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

View File

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