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

Commit 9efc532a authored by Billy Huang's avatar Billy Huang
Browse files

Store lockout end time as rate limiter state

Instead of computing the lockout end time on-the-fly,
update it as state when successes/failures are reported,
and compute it on the first retrieval for the LskfId
after a cold boot. There is no observable behavior change
outside of the SoftwareRateLimiter.

The state will be queried in the next CL to implement
getLockoutEndTime. It will also track external timeouts.

Bug: 322014085
Flag: EXEMPT PURE_REFACTOR
Test: atest FrameworksServicesTests:com.android.server.locksettings
Change-Id: I5df3c2da835062575ab15524d8078d1251404bf0
parent 997901f7
Loading
Loading
Loading
Loading
+108 −39
Original line number Diff line number Diff line
@@ -141,8 +141,12 @@ class SoftwareRateLimiter {
         */
        public int numDuplicateWrongGuesses;

        /** The type of the LSKF, as a value of the stats CredentialType enum */
        public final int statsCredentialType;
        /**
         * The type of the LSKF, as a value of the stats CredentialType enum. Updates after the
         * first guess.
         */
        public int statsCredentialType =
                FrameworkStatsLog.LSKF_AUTHENTICATION_ATTEMPTED__CREDENTIAL_TYPE__UNKNOWN_TYPE;

        /**
         * The time since boot at which the failure counter was last incremented, or zero if the
@@ -150,6 +154,13 @@ class SoftwareRateLimiter {
         */
        public Duration timeSinceBootOfLastFailure = Duration.ZERO;

        /**
         * The time since boot at which the lockout ends and another guess can be made, or {@link
         * Duration#ZERO} if there is not currently a lockout. Lockouts can be imposed by software
         * or externally, e.g. Weaver.
         */
        public Duration lockoutEndTime = Duration.ZERO;

        /**
         * The list of wrong guesses that were recently tried already in the current boot, ordered
         * from newest to oldest. The used portion is followed by nulls in any unused space.
@@ -157,9 +168,8 @@ class SoftwareRateLimiter {
        public final LockscreenCredential[] savedWrongGuesses =
                new LockscreenCredential[MAX_SAVED_WRONG_GUESSES];

        RateLimiterState(int numFailures, LockscreenCredential firstGuess) {
        RateLimiterState(int numFailures) {
            this.numFailures = numFailures;
            this.statsCredentialType = getStatsCredentialType(firstGuess);
        }
    }

@@ -196,41 +206,23 @@ class SoftwareRateLimiter {
            return SoftwareRateLimiterResult.credentialTooShort();
        }

        final RateLimiterState state =
                mState.computeIfAbsent(
                        id,
                        key -> {
                            // The state isn't cached yet. Create it.
                            //
                            // For LSKF-based synthetic password protectors the only persistent
                            // software rate-limiter state is the failure counter.
                            // timeSinceBootOfLastFailure is just set to zero, so effectively the
                            // timeout resets to its original value (for the current failure count)
                            // upon reboot. That matches what typical hardware rate-limiter
                            // implementations do; they typically do not have access to a trusted
                            // real-time clock that runs without the device being powered on.
                            //
                            // Likewise, rebooting causes any saved wrong guesses to be forgotten.
                            return new RateLimiterState(readFailureCounter(id), guess);
                        });
        final Duration now = mInjector.getTimeSinceBoot();
        final RateLimiterState state = getOrComputeState(id, now);
        if (state.statsCredentialType
                == FrameworkStatsLog.LSKF_AUTHENTICATION_ATTEMPTED__CREDENTIAL_TYPE__UNKNOWN_TYPE) {
            state.statsCredentialType = getStatsCredentialType(guess);
        }

        // Check for remaining timeout. Note that the case of a positive remaining timeout normally
        // won't be reached, since reportFailure() will have returned the timeout when the last
        // guess was made, causing the lock screen to block inputs for that amount of time. But
        // checking for it is still needed to cover any cases where a guess gets made anyway, for
        // example following a reboot which causes the lock screen to "forget" the timeout.
        final Duration originalTimeout;
        if (mEnforcing) {
            if (state.numFailures >= TIMEOUT_TABLE.length || state.numFailures < 0) {
        if (mEnforcing && (state.numFailures >= TIMEOUT_TABLE.length || state.numFailures < 0)) {
            Slogf.e(TAG, "No more guesses allowed; numFailures=%d", state.numFailures);
            return SoftwareRateLimiterResult.noMoreGuesses();
        }
            originalTimeout = TIMEOUT_TABLE[state.numFailures];
        } else {
            originalTimeout = Duration.ZERO;
        }
        final Duration now = mInjector.getTimeSinceBoot();
        final Duration timeout = state.timeSinceBootOfLastFailure.plus(originalTimeout).minus(now);
        final Duration timeout = computeRemainingTimeout(state, now);
        if (timeout.isPositive()) {
            Slogf.e(TAG, "Rate-limited; numFailures=%d, timeout=%s", state.numFailures, timeout);
            return SoftwareRateLimiterResult.rateLimited(timeout);
@@ -259,6 +251,68 @@ class SoftwareRateLimiter {
        return SoftwareRateLimiterResult.continueToHardware();
    }

    @GuardedBy("this")
    private RateLimiterState getOrComputeState(LskfIdentifier id, Duration now) {
        return mState.computeIfAbsent(
                id,
                key -> {
                    // The state isn't cached yet. Create it.
                    //
                    // For LSKF-based synthetic password protectors the only persistent
                    // software rate-limiter state is the failure counter.
                    // timeSinceBootOfLastFailure is just set to zero, so effectively the
                    // timeout resets to its original value (for the current failure count)
                    // upon reboot. That matches what typical hardware rate-limiter
                    // implementations do; they typically do not have access to a trusted
                    // real-time clock that runs without the device being powered on.
                    //
                    // Likewise, rebooting causes any saved wrong guesses to be forgotten.
                    RateLimiterState state = new RateLimiterState(readFailureCounter(id));
                    evaluateSoftwareRateLimit(state, now);
                    return state;
                });
    }

    @GuardedBy("this")
    private Duration computeRemainingTimeout(RateLimiterState state, Duration now) {
        final Duration remainingTimeout = state.lockoutEndTime.minus(now);
        return remainingTimeout.isPositive() ? remainingTimeout : Duration.ZERO;
    }

    /** Computes the software enforced lockout and updates the stored lockout end time. */
    @GuardedBy("this")
    private void evaluateSoftwareRateLimit(RateLimiterState state, Duration now) {
        final Duration originalTimeout = getOriginalTimeout(state.numFailures);
        final Duration softwareLockoutEndTime =
                state.timeSinceBootOfLastFailure.plus(originalTimeout);
        updateLockoutEndTime(state, now, softwareLockoutEndTime);
    }

    /**
     * Updates state.lockoutEndTime to be the later of lockoutEndTime and state.lockoutEndTime, or
     * zero if that time has already been reached.
     */
    @GuardedBy("this")
    private void updateLockoutEndTime(
            RateLimiterState state, Duration now, Duration lockoutEndTime) {
        if (state.lockoutEndTime.compareTo(lockoutEndTime) > 0) {
            lockoutEndTime = state.lockoutEndTime; // state.lockoutEndTime is later
        }
        if (now.compareTo(lockoutEndTime) >= 0) {
            lockoutEndTime = Duration.ZERO; // end time has already been reached
        }
        if (!lockoutEndTime.equals(state.lockoutEndTime)) {
            state.lockoutEndTime = lockoutEndTime;
        }
    }

    @GuardedBy("this")
    private void clearLockoutEndTime(RateLimiterState state) {
        if (!state.lockoutEndTime.isZero()) {
            state.lockoutEndTime = Duration.ZERO;
        }
    }

    /**
     * Reports a successful guess to the software rate-limiter. This causes the failure counter and
     * saved wrong guesses to be cleared.
@@ -274,6 +328,7 @@ class SoftwareRateLimiter {
            state.numDuplicateWrongGuesses = 0;
            writeFailureCounter(id, state);
            forgetSavedWrongGuesses(state);
            clearLockoutEndTime(state);
        }
    }

@@ -318,6 +373,8 @@ class SoftwareRateLimiter {
            return Duration.ZERO;
        }

        final Duration now = mInjector.getTimeSinceBoot();

        // Increment the failure counter regardless of whether the failure is a certainly wrong
        // guess or not. A generic failure might still be caused by a wrong guess. Gatekeeper only
        // ever returns generic failures, and some Weaver implementations prefer THROTTLE to
@@ -327,7 +384,9 @@ class SoftwareRateLimiter {
        // rate-limiter kicks in gradually anyway, so there will be a chance for the user to try
        // again.
        state.numFailures++;
        state.timeSinceBootOfLastFailure = mInjector.getTimeSinceBoot();
        state.timeSinceBootOfLastFailure = now;

        evaluateSoftwareRateLimit(state, now);

        // Update the counter on-disk. It is important that this be done before the failure is
        // reported to the UI, and that it be done synchronously e.g. by fsync()-ing the file and
@@ -358,15 +417,19 @@ class SoftwareRateLimiter {
                    SAVED_WRONG_GUESS_TIMEOUT.toMillis());
        }

        return computeRemainingTimeout(state, now);
    }

    private Duration getOriginalTimeout(int numFailures) {
        if (!mEnforcing) {
            return Duration.ZERO;
        }
        if (state.numFailures >= TIMEOUT_TABLE.length || state.numFailures < 0) {
        if (numFailures >= TIMEOUT_TABLE.length || numFailures < 0) {
            // In this case actually no more guesses are allowed, but currently there is no way to
            // convey that information. For now just report the final timeout again.
            return TIMEOUT_TABLE[TIMEOUT_TABLE.length - 1];
        }
        return TIMEOUT_TABLE[state.numFailures];
        return TIMEOUT_TABLE[numFailures];
    }

    private static int getStatsCredentialType(LockscreenCredential firstGuess) {
@@ -435,8 +498,7 @@ class SoftwareRateLimiter {
    synchronized void clearLskfState(LskfIdentifier id) {
        int index = mState.indexOfKey(id);
        if (index >= 0) {
            forgetSavedWrongGuesses(mState.valueAt(index));
            mState.removeAt(index);
            clearLskfStateAtIndex(index);
        }
    }

@@ -449,12 +511,18 @@ class SoftwareRateLimiter {
        for (int index = mState.size() - 1; index >= 0; index--) {
            LskfIdentifier id = mState.keyAt(index);
            if (id.userId == userId) {
                forgetSavedWrongGuesses(mState.valueAt(index));
                mState.removeAt(index);
                clearLskfStateAtIndex(index);
            }
        }
    }

    @GuardedBy("this")
    private void clearLskfStateAtIndex(int index) {
        final RateLimiterState state = mState.valueAt(index);
        forgetSavedWrongGuesses(state);
        mState.removeAt(index);
    }

    private int readFailureCounter(LskfIdentifier id) {
        if (id.isSpecialCredential()) {
            // Special credentials (e.g. FRP credential and repair mode exit credential) do not yet
@@ -492,6 +560,7 @@ class SoftwareRateLimiter {
            pw.println("numDuplicateWrongGuesses=" + state.numDuplicateWrongGuesses);
            pw.println("statsCredentialType=" + state.statsCredentialType);
            pw.println("timeSinceBootOfLastFailure=" + state.timeSinceBootOfLastFailure);
            pw.println("lockoutEndTime=" + state.lockoutEndTime);
            pw.println(
                    "numSavedWrongGuesses="
                            + Arrays.stream(state.savedWrongGuesses)
+20 −22
Original line number Diff line number Diff line
@@ -74,6 +74,8 @@ import java.time.Duration;
@Presubmit
@RunWith(AndroidJUnit4.class)
public class LockSettingsServiceTests extends BaseLockSettingsServiceTests {
    private static final Duration TEN_YEARS = Duration.ofDays(10 * 365);

    @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
    @Rule
    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
@@ -840,18 +842,11 @@ public class LockSettingsServiceTests extends BaseLockSettingsServiceTests {
    public void test20UniqueGuessesAllowed() throws Exception {
        final int userId = PRIMARY_USER_ID;
        final LockscreenCredential credential = newPassword("password");
        final Duration tenYears = Duration.ofDays(10 * 365);
        Duration now = Duration.ZERO;
        VerifyCredentialResponse response;

        mInjector.setTimeSinceBoot(now);
        mInjector.setTimeSinceBoot(Duration.ZERO);
        setCredential(userId, credential);
        for (int i = 0; i < 19; i++) {
            response = mService.verifyCredential(newPassword("wrong" + i), userId, /* flags= */ 0);
            assertFalse(response.isMatched());
            now = now.plus(tenYears); // Advance 10 years to get past rate-limiting
            mInjector.setTimeSinceBoot(now);
        }
        guessWrongCredential(userId, 19, TEN_YEARS);
        response = mService.verifyCredential(credential, userId, /* flags= */ 0);
        assertTrue(response.isMatched());
    }
@@ -861,18 +856,11 @@ public class LockSettingsServiceTests extends BaseLockSettingsServiceTests {
    public void testMoreThan20UniqueGuessesNotAllowed() throws Exception {
        final int userId = PRIMARY_USER_ID;
        final LockscreenCredential credential = newPassword("password");
        final Duration tenYears = Duration.ofDays(10 * 365);
        Duration now = Duration.ZERO;
        VerifyCredentialResponse response;

        mInjector.setTimeSinceBoot(now);
        mInjector.setTimeSinceBoot(Duration.ZERO);
        setCredential(userId, credential);
        for (int i = 0; i < 20; i++) {
            response = mService.verifyCredential(newPassword("wrong" + i), userId, /* flags= */ 0);
            assertFalse(response.isMatched());
            now = now.plus(tenYears); // Advance 10 years to get past rate-limiting
            mInjector.setTimeSinceBoot(now);
        }
        guessWrongCredential(userId, 20, TEN_YEARS);
        response = mService.verifyCredential(credential, userId, /* flags= */ 0);
        assertFalse(response.isMatched());
    }
@@ -886,10 +874,7 @@ public class LockSettingsServiceTests extends BaseLockSettingsServiceTests {
        VerifyCredentialResponse response;

        setCredential(userId, credential);
        for (int i = 0; i < 20; i++) {
            response = mService.verifyCredential(newPassword("wrong" + i), userId, /* flags= */ 0);
            assertFalse(response.isMatched());
        }
        guessWrongCredential(userId, /* times= */ 20);
        response = mService.verifyCredential(credential, userId, /* flags= */ 0);
        assertTrue(response.isMatched());
    }
@@ -911,6 +896,19 @@ public class LockSettingsServiceTests extends BaseLockSettingsServiceTests {
        testTimeoutClamping(Duration.ofMillis(Long.MAX_VALUE), Integer.MAX_VALUE);
    }

    private void guessWrongCredential(int userId, int times) {
        guessWrongCredential(userId, times, Duration.ZERO);
    }

    private void guessWrongCredential(int userId, int times, Duration timeBetweenGuesses) {
        for (int i = 0; i < times; i++) {
            VerifyCredentialResponse response =
                    mService.verifyCredential(newPassword("wrong" + i), userId, /* flags= */ 0);
            assertFalse(response.isMatched());
            mInjector.setTimeSinceBoot(mInjector.getTimeSinceBoot().plus(timeBetweenGuesses));
        }
    }

    private void testTimeoutClamping(Duration originalTimeout, int expectedClampedTimeout) {
        VerifyCredentialResponse response = VerifyCredentialResponse.fromTimeout(originalTimeout);
        assertEquals(expectedClampedTimeout, response.getTimeout());