Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 272b953b authored by TreeHugger Robot's avatar TreeHugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Move TertiaryKeyRotationScheduler and TertiaryKeyRotationWindowedCount"

parents b4c455b7 bb45da74
Loading
Loading
Loading
Loading
+104 −0
Original line number Diff line number Diff line
/*
 * 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();
    }
}
+21 −7
Original line number Diff line number Diff line
/*
 * 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();
    }
+132 −0
Original line number Diff line number Diff line
/*
 * 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);
            }
        }
    }
}
+200 −0
Original line number Diff line number Diff line
/*
 * 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);
    }
}
+131 −0
Original line number Diff line number Diff line
/*
 * 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);
    }
}