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

Commit f9a21d0b authored by Eric Biggers's avatar Eric Biggers
Browse files

Detect duplicates only of certainly-wrong guesses

Make the SoftwareRateLimiter detect duplicates only of guesses that are
known for certain to be wrong guesses, due to Weaver reporting a status
of INCORRECT_KEY.  Otherwise the failure may have another cause and the
guess may be correct, so retries of the same guess should not be blocked
by the duplicate wrong guess detection.

For now this applies only to a status of INCORRECT_KEY combined with a
zero timeout.  A later CL will handle INCORRECT_KEY with nonzero
timeout.  For now that case is still treated as a generic failure.

To unit-test this change properly, add the ability to inject responses
into MockWeaverService.  Test the behavior of LockSettingsService when
it encounters different responses from Weaver.

Test: atest FrameworksServicesTests:com.android.server.locksettings
Bug: 395976735
Flag: android.security.software_ratelimiter
Change-Id: I4a3ee8456d1220d1dd97ac8859a84c13dfaca575
parent b99e9ff1
Loading
Loading
Loading
Loading
+6 −2
Original line number Diff line number Diff line
@@ -2549,8 +2549,12 @@ public class LockSettingsService extends ILockSettings.Stub {
            if (response.isMatched()) {
                mSoftwareRateLimiter.reportSuccess(lskfId);
            } else {
                // TODO(b/395976735): don't count transient failures
                Duration swTimeout = mSoftwareRateLimiter.reportWrongGuess(lskfId, credential);
                boolean isCertainlyWrongGuess =
                        response.getResponseCode()
                                == VerifyCredentialResponse.RESPONSE_CRED_INCORRECT;
                Duration swTimeout =
                        mSoftwareRateLimiter.reportFailure(
                                lskfId, credential, isCertainlyWrongGuess);

                // The software rate-limiter may use longer delays than the hardware one. While the
                // long-term solution is to update the hardware rate-limiter to match, for now this
+44 −31
Original line number Diff line number Diff line
@@ -224,10 +224,10 @@ class SoftwareRateLimiter {
                        });

        // Check for remaining delay. Note that the case of a positive remaining delay normally
        // won't be reached, since reportWrongGuess() will have returned the delay 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 delay.
        // won't be reached, since reportFailure() will have returned the delay 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 delay.
        final Duration delay = getCurrentDelay(state);
        final Duration now = mInjector.getTimeSinceBoot();
        final Duration remainingDelay = state.timeSinceBootOfLastWrongGuess.plus(delay).minus(now);
@@ -298,27 +298,37 @@ class SoftwareRateLimiter {
    }

    /**
     * Reports a new wrong guess to the software rate-limiter.
     * Reports a failure to the software rate-limiter.
     *
     * <p>This must be called immediately after the hardware rate-limiter reported that the given
     * guess is incorrect, before the credential check failure is made visible in the UI. It is
     * assumed that {@link #apply(LskfIdentifier, LockscreenCredential)} was previously called with
     * the same parameters and returned a {@code CONTINUE_TO_HARDWARE} result.
     * <p>This must be called immediately after the hardware rate-limiter reported a failure, before
     * the credential check failure is made visible in the UI. It is assumed that {@link
     * #apply(LskfIdentifier, LockscreenCredential)} was previously called with the same parameters
     * and returned a {@code CONTINUE_TO_HARDWARE} result.
     *
     * @param id the ID of the protector or special credential
     * @param newWrongGuess a new wrong guess for the LSKF
     * @param guess the LSKF that was attempted
     * @param isCertainlyWrongGuess true if it's certain that the failure was caused by the guess
     *     being wrong, as opposed to e.g. a transient hardware glitch
     * @return the delay until when the next guess will be allowed
     */
    synchronized Duration reportWrongGuess(LskfIdentifier id, LockscreenCredential newWrongGuess) {
    synchronized Duration reportFailure(
            LskfIdentifier id, LockscreenCredential guess, boolean isCertainlyWrongGuess) {
        RateLimiterState state = getExistingState(id);

        // In non-enforcing mode, ignore duplicate wrong guesses here since they were already
        // counted by apply(), including having stats written for them. In enforcing mode, this
        // method isn't passed duplicate wrong guesses.
        if (!mEnforcing && ArrayUtils.contains(state.savedWrongGuesses, newWrongGuess)) {
        if (!mEnforcing && ArrayUtils.contains(state.savedWrongGuesses, guess)) {
            return Duration.ZERO;
        }

        // 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
        // INCORRECT_KEY once the delay becomes nonzero. Instead of making the software rate-limiter
        // ineffective on all such devices, still apply it. This does mean that correct guesses that
        // encountered an error will be rate-limited. However, by design the rate-limiter kicks in
        // gradually anyway, so there will be a chance for the user to try again.
        state.numWrongGuesses++;
        state.timeSinceBootOfLastWrongGuess = mInjector.getTimeSinceBoot();

@@ -329,7 +339,9 @@ class SoftwareRateLimiter {

        writeStats(id, state, /* success= */ false);

        insertNewWrongGuess(state, newWrongGuess);
        // Save certainly wrong guesses so that duplicates of them can be detected.
        if (isCertainlyWrongGuess) {
            insertNewWrongGuess(state, guess);

            // Schedule the saved wrong guesses to be forgotten after a few minutes, extending the
            // existing timeout if one was already running.
@@ -347,6 +359,7 @@ class SoftwareRateLimiter {
                    },
                    /* token= */ state,
                    SAVED_WRONG_GUESS_TIMEOUT.toMillis());
        }

        return getCurrentDelay(state);
    }
@@ -384,7 +397,7 @@ class SoftwareRateLimiter {
    private RateLimiterState getExistingState(LskfIdentifier id) {
        RateLimiterState state = mState.get(id);
        if (state == null) {
            // This should never happen, since reportSuccess() and reportWrongGuess() are always
            // This should never happen, since reportSuccess() and reportFailure() are always
            // supposed to be paired with a call to apply() that created the state if it did not
            // exist. Nor is it supported to call clearLskfState() or clearUserState() in between;
            // higher-level locking in LockSettingsService guarantees that never happens.
+0 −3
Original line number Diff line number Diff line
@@ -723,8 +723,6 @@ class SyntheticPasswordManager {
                // the credential was incorrect and there is a timeout before the next attempt will
                // be allowed. INCORRECT_KEY is preferred in the latter case to avoid the ambiguity,
                // but we still have to support implementations that use THROTTLE for both cases.
                //
                // TODO(b/395976735): needs unit testing via MockWeaverService
                return responseFromTimeout(weaverResponse);
            case WeaverReadStatus.INCORRECT_KEY:
                if (weaverResponse.timeout != 0) {
@@ -734,7 +732,6 @@ class SyntheticPasswordManager {
                    //
                    // TODO(b/395976735): use RESPONSE_CRED_INCORRECT in this case, and update users
                    // of VerifyCredentialResponse to be compatible with that.
                    // TODO(b/395976735): needs unit testing via MockWeaverService
                    return responseFromTimeout(weaverResponse);
                }
                if (android.security.Flags.softwareRatelimiter()) {
+106 −0
Original line number Diff line number Diff line
@@ -39,6 +39,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.content.Intent;
import android.hardware.weaver.WeaverReadStatus;
import android.os.RemoteException;
import android.os.UserHandle;
import android.platform.test.annotations.DisableFlags;
@@ -57,10 +58,13 @@ import com.android.internal.widget.LockscreenCredential;
import com.android.internal.widget.VerifyCredentialResponse;

import org.junit.Before;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.time.Duration;

/**
 * atest FrameworksServicesTests:LockSettingsServiceTests
 */
@@ -719,6 +723,108 @@ public class LockSettingsServiceTests extends BaseLockSettingsServiceTests {
        assertEquals(VerifyCredentialResponse.RESPONSE_OTHER_ERROR, response.getResponseCode());
    }

    // Tests that if verifyCredential is passed a wrong guess and Weaver reports INCORRECT_KEY with
    // zero timeout (which indicates a certainly wrong guess), then LockSettingsService saves that
    // guess as a recent wrong guess and rejects a repeat of it as a duplicate.
    @Test
    @EnableFlags(android.security.Flags.FLAG_SOFTWARE_RATELIMITER)
    public void testRepeatOfWrongGuessRejectedAsDuplicate_afterWeaverIncorrectKeyWithoutTimeout()
            throws Exception {
        final int userId = PRIMARY_USER_ID;
        final LockscreenCredential credential = newPassword("password");
        final LockscreenCredential wrongGuess = newPassword("wrong");

        mSpManager.enableWeaver();
        setCredential(userId, credential);

        mSpManager.injectWeaverReadResponse(WeaverReadStatus.INCORRECT_KEY, Duration.ZERO);
        VerifyCredentialResponse response =
                mService.verifyCredential(wrongGuess, userId, /* flags= */ 0);
        assertEquals(VerifyCredentialResponse.RESPONSE_CRED_INCORRECT, response.getResponseCode());
        assertEquals(Duration.ZERO, response.getTimeoutAsDuration());

        response = mService.verifyCredential(wrongGuess, userId, /* flags= */ 0);
        assertEquals(
                VerifyCredentialResponse.RESPONSE_CRED_ALREADY_TRIED, response.getResponseCode());
        assertEquals(Duration.ZERO, response.getTimeoutAsDuration());
    }

    // Same as preceding test case, but uses a nonzero timeout.
    //
    // TODO(b/395976735): currently the behavior in this scenario is wrong, so currently this test
    // case is ignored. Fix the behavior and remove @Ignore.
    @Ignore
    @Test
    @EnableFlags(android.security.Flags.FLAG_SOFTWARE_RATELIMITER)
    public void testRepeatOfWrongGuessRejectedAsDuplicate_afterWeaverIncorrectKeyWithTimeout()
            throws Exception {
        final int userId = PRIMARY_USER_ID;
        final LockscreenCredential credential = newPassword("password");
        final LockscreenCredential wrongGuess = newPassword("wrong");
        final Duration timeout = Duration.ofSeconds(60);

        mSpManager.enableWeaver();
        setCredential(userId, credential);

        mSpManager.injectWeaverReadResponse(WeaverReadStatus.INCORRECT_KEY, timeout);
        VerifyCredentialResponse response =
                mService.verifyCredential(wrongGuess, userId, /* flags= */ 0);
        assertEquals(VerifyCredentialResponse.RESPONSE_RETRY, response.getResponseCode());
        assertEquals(timeout, response.getTimeoutAsDuration());

        response = mService.verifyCredential(wrongGuess, userId, /* flags= */ 0);
        assertEquals(
                VerifyCredentialResponse.RESPONSE_CRED_ALREADY_TRIED, response.getResponseCode());
        assertEquals(Duration.ZERO, response.getTimeoutAsDuration());
    }

    // Tests that if verifyCredential is passed a correct guess but it fails due to Weaver reporting
    // a status of THROTTLE (which is the expected status when there is a remaining rate-limiting
    // delay in Weaver), then LockSettingsService does not block the same guess from being
    // re-attempted and in particular does not reject it as a duplicate wrong guess.
    @Test
    @EnableFlags(android.security.Flags.FLAG_SOFTWARE_RATELIMITER)
    public void testRepeatOfCorrectGuessAllowed_afterWeaverThrottle() throws Exception {
        final int userId = PRIMARY_USER_ID;
        final LockscreenCredential credential = newPassword("password");
        final Duration timeout = Duration.ofSeconds(60);

        mSpManager.enableWeaver();
        setCredential(userId, credential);

        mSpManager.injectWeaverReadResponse(WeaverReadStatus.THROTTLE, timeout);
        VerifyCredentialResponse response =
                mService.verifyCredential(credential, userId, /* flags= */ 0);
        assertEquals(VerifyCredentialResponse.RESPONSE_RETRY, response.getResponseCode());
        assertEquals(timeout, response.getTimeoutAsDuration());

        response = mService.verifyCredential(credential, userId, /* flags= */ 0);
        assertTrue(response.isMatched());
    }

    // Tests that if verifyCredential is passed a correct guess but it fails due to Weaver reporting
    // a status of FAILED (which is the expected status when there is a transient error unrelated to
    // the guess), then LockSettingsService does not block the same guess from being re-attempted
    // and in particular does not reject it as a duplicate wrong guess.
    @Test
    @EnableFlags(android.security.Flags.FLAG_SOFTWARE_RATELIMITER)
    public void testRepeatOfCorrectGuessAllowed_afterWeaverFailed() throws Exception {
        final int userId = PRIMARY_USER_ID;
        final LockscreenCredential credential = newPassword("password");

        mSpManager.enableWeaver();
        setCredential(userId, credential);

        mSpManager.injectWeaverReadResponse(WeaverReadStatus.FAILED, Duration.ZERO);
        VerifyCredentialResponse response =
                mService.verifyCredential(credential, userId, /* flags= */ 0);
        assertEquals(VerifyCredentialResponse.RESPONSE_OTHER_ERROR, response.getResponseCode());
        assertEquals(Duration.ZERO, response.getTimeoutAsDuration());

        response = mService.verifyCredential(credential, userId, /* flags= */ 0);
        assertTrue(response.isMatched());
    }

    private void checkRecordedFrpNotificationIntent() {
        if (android.security.Flags.frpEnforcement()) {
            Intent savedNotificationIntent = mService.getSavedFrpNotificationIntent();
+6 −0
Original line number Diff line number Diff line
@@ -28,6 +28,7 @@ import junit.framework.AssertionFailedError;
import java.nio.ByteBuffer;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.time.Duration;
import java.util.Arrays;

import javax.crypto.SecretKeyFactory;
@@ -152,4 +153,9 @@ public class MockSyntheticPasswordManager extends SyntheticPasswordManager {
    public int getSumOfWeaverFailureCounters() {
        return mWeaverService.getSumOfFailureCounters();
    }

    /** Injects a response to be returned by the next read from Weaver. */
    public void injectWeaverReadResponse(int status, Duration timeout) {
        mWeaverService.injectReadResponse(status, timeout);
    }
}
Loading