Skip to content
Open
12 changes: 12 additions & 0 deletions app/src/main/java/com/beemdevelopment/aegis/Preferences.java
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,18 @@ public void setBuiltInBackupResult(@Nullable BackupResult res) {
setBackupResult(true, res);
}

public boolean isDataWipingEnabled() {
return _prefs.getBoolean("pref_enable_data_wiping", false);
}

public int getMaxFailedAttemptsBeforeWipe() {
return _prefs.getInt("pref_max_failed_attempts", 10);
}

public void setMaxFailedAttemptsBeforeWipe(int attempts) {
_prefs.edit().putInt("pref_max_failed_attempts", attempts).apply();
}
Comment on lines +488 to +494

@Nullable
public BackupResult getAndroidBackupResult() {
return getBackupResult(false);
Expand Down
240 changes: 232 additions & 8 deletions app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.beemdevelopment.aegis.ui;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.CountDownTimer;
import android.text.InputType;
import android.view.KeyEvent;
import android.view.View;
Expand Down Expand Up @@ -65,6 +67,15 @@ public class AuthActivity extends AegisActivity {

private int _failedUnlockAttempts;

private static final String PREFS_NAME = "auth_prefs";
private static final String KEY_FAILED_ATTEMPTS = "failed_attempts";
private static final String KEY_LOCKOUT_UNTIL = "lockout_until";

private long _lockoutUntil = 0;

private TextView _textLockout;
private CountDownTimer _lockoutTimer;

// the first time this activity is resumed after creation, it's possible to inhibit showing the
// biometric prompt by setting 'inhibitBioPrompt' to true through the intent
private boolean _inhibitBioPrompt;
Expand All @@ -74,11 +85,18 @@ protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_auth);

_failedUnlockAttempts = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getInt(KEY_FAILED_ATTEMPTS, 0);
_lockoutUntil = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getLong(KEY_LOCKOUT_UNTIL, 0);

TextInputLayout layoutStandard = findViewById(R.id.layout_standard);
TextInputLayout layoutNoAutofill = findViewById(R.id.layout_no_autofill);
EditText editStandard = findViewById(R.id.text_password);
EditText editNoAutofill = findViewById(R.id.text_password_no_autofill);

_textLockout = findViewById(R.id.lockout_message);

updateFailedAttemptsUI();

if (_prefs.isPinKeyboardEnabled()) {
layoutStandard.setVisibility(View.GONE);
layoutNoAutofill.setVisibility(View.VISIBLE);
Expand All @@ -91,6 +109,11 @@ protected void onCreate(Bundle savedInstanceState) {

LinearLayout boxBiometricInfo = findViewById(R.id.box_biometric_info);
_decryptButton = findViewById(R.id.button_decrypt);

if (isLockedOut()) {
startLockoutCountdown();
}

TextView biometricsButton = findViewById(R.id.button_biometrics);

getOnBackPressedDispatcher().addCallback(this, new BackPressHandler());
Expand Down Expand Up @@ -168,7 +191,17 @@ protected void onCreate(Bundle savedInstanceState) {
InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(v.getWindowToken(), 0);

if (isLockedOut()) {
return;
}

char[] password = EditTextHelper.getEditTextChars(_textPassword);

if (password.length == 0) {
Toast.makeText(AuthActivity.this, getString(R.string.error_empty_password), Toast.LENGTH_SHORT).show();
return;
}

List<PasswordSlot> slots = _slots.findAll(PasswordSlot.class);
PasswordSlotDecryptTask.Params params = new PasswordSlotDecryptTask.Params(slots, password);
PasswordSlotDecryptTask task = new PasswordSlotDecryptTask(AuthActivity.this, new PasswordDerivationListener());
Expand Down Expand Up @@ -220,11 +253,20 @@ public void onResume() {
_bioPrompt = showBiometricPrompt();
}

if (isLockedOut()) {
startLockoutCountdown();
}

_inhibitBioPrompt = false;
}

@Override
public void onPause() {
if (_lockoutTimer != null) {
_lockoutTimer.cancel();
_lockoutTimer = null;
}

if (!isChangingConfigurations() && _bioPrompt != null) {
_bioPrompt.cancelAuthentication();
_bioPrompt = null;
Expand Down Expand Up @@ -306,26 +348,168 @@ private void finish(MasterKey key, boolean isSlotRepaired) {
return;
}

_failedUnlockAttempts = 0;
_lockoutUntil = 0;
saveFailedAttempts();
saveLockoutUntil();
updateFailedAttemptsUI();

if (_lockoutTimer != null) {
_lockoutTimer.cancel();
_lockoutTimer = null;
}

_textLockout.setText("");
_decryptButton.setEnabled(true);

setResult(RESULT_OK);
finish();
}

private void onInvalidPassword() {
Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(AuthActivity.this, R.style.ThemeOverlay_Aegis_AlertDialog_Error)
.setTitle(getString(R.string.unlock_vault_error))
.setMessage(getString(R.string.unlock_vault_error_description))
.setCancelable(false)
.setIconAttribute(android.R.attr.alertDialogIcon)
.setPositiveButton(android.R.string.ok, (dialog, which) -> selectPassword())
.create());
_failedUnlockAttempts++;
applyLockout();
saveFailedAttempts();

if (shouldWipeVault()) {
wipeVaultAndExit();
return;
}

_failedUnlockAttempts ++;
if (_prefs.isDataWipingEnabled() && _failedUnlockAttempts == _prefs.getMaxFailedAttemptsBeforeWipe() - 1) {
showDangerDialog();
} else {
Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(AuthActivity.this, R.style.ThemeOverlay_Aegis_AlertDialog_Error)
.setTitle(getString(R.string.unlock_vault_error))
.setMessage(getString(R.string.unlock_vault_error_description))
.setCancelable(false)
.setIconAttribute(android.R.attr.alertDialogIcon)
.setPositiveButton(android.R.string.ok, (dialog, which) -> selectPassword())
.create());
}

updateFailedAttemptsUI();

if (isLockedOut()) {
startLockoutCountdown();
}

if (_failedUnlockAttempts >= 3) {
_textPassword.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
}
}

private void updateFailedAttemptsUI() {
if (_textLockout != null) {
if (isLockedOut()) {
_textLockout.setVisibility(View.VISIBLE);
} else {
_textLockout.setText("");
_textLockout.setVisibility(View.GONE);
}
}
}

private void saveFailedAttempts() {
getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.edit()
.putInt(KEY_FAILED_ATTEMPTS, _failedUnlockAttempts)
.apply();
}

private void saveLockoutUntil() {
getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
.edit()
.putLong(KEY_LOCKOUT_UNTIL, _lockoutUntil)
.apply();
}

private boolean isLockedOut() {
return System.currentTimeMillis() < _lockoutUntil;
}

private long getRemainingLockoutMillis() {
return Math.max(0, _lockoutUntil - System.currentTimeMillis());
}

private void applyLockout() {
if (_failedUnlockAttempts < 3) {
return;
}

int step = _failedUnlockAttempts - 3;
long base = 60_000; // 1 min

long timeoutMillis = base * (step + 1) * (step + 2) / 2;

long maxTimeout = 60 * 60_000; // 1 hour
timeoutMillis = Math.min(timeoutMillis, maxTimeout);

_lockoutUntil = System.currentTimeMillis() + timeoutMillis;
saveLockoutUntil();
}

private void startLockoutCountdown() {
if (_lockoutTimer != null) {
_lockoutTimer.cancel();
}

long remaining = getRemainingLockoutMillis();

if (remaining <= 0) {
_textLockout.setText("");
_decryptButton.setEnabled(true);
return;
}

_decryptButton.setEnabled(false);

_lockoutTimer = new CountDownTimer(remaining, 1000) {
@Override
public void onTick(long millisUntilFinished) {
long totalSeconds = (millisUntilFinished + 999) / 1000;

long minutes = totalSeconds / 60;
long seconds = totalSeconds % 60;

@SuppressLint("DefaultLocale") String timeFormatted = String.format("%02d:%02d", minutes, seconds);

_textLockout.setText(
getString(R.string.lockout_message, _failedUnlockAttempts, timeFormatted)
);
Comment on lines +472 to +479
}

@Override
public void onFinish() {
_textLockout.setText("");
_textLockout.setVisibility(View.GONE);
_decryptButton.setEnabled(true);
_lockoutTimer = null;
}
}.start();
}

private boolean shouldWipeVault() {
return _prefs.isDataWipingEnabled() && _failedUnlockAttempts >= _prefs.getMaxFailedAttemptsBeforeWipe();
}

private void wipeVaultAndExit() {
_failedUnlockAttempts = 0;
_lockoutUntil = 0;
saveFailedAttempts();
saveLockoutUntil();

VaultRepository.deleteFile(this);
_vaultManager.lock(false);

finishApp();
}

private void finishApp() {
ExitActivity.exitAppAndRemoveFromRecents(this);
finishAndRemoveTask();
}

private class BackPressHandler extends OnBackPressedCallback {
public BackPressHandler() {
super(true);
Expand Down Expand Up @@ -363,6 +547,46 @@ public void onTaskFinished(PasswordSlotDecryptTask.Result result) {
}
}

private void showDangerDialog() {
final int delayMillis = 5000;

androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(
AuthActivity.this,
R.style.ThemeOverlay_Aegis_AlertDialog_Error
)
.setTitle(getString(R.string.unlock_vault_error_danger))
.setMessage(getString(R.string.unlock_vault_error_description_danger))
.setCancelable(false)
.setIcon(R.drawable.ic_warning_24)
.setPositiveButton(getString(android.R.string.ok), null)
.create();

dialog.setOnShowListener(d -> {
Button positiveButton = dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(false);

new CountDownTimer(delayMillis, 1000) {
@Override
public void onTick(long millisUntilFinished) {
long secondsLeft = (millisUntilFinished + 999) / 1000;
positiveButton.setText(getString(R.string.ok_with_timer, secondsLeft));
}

@Override
public void onFinish() {
positiveButton.setText(getString(android.R.string.ok));
positiveButton.setEnabled(true);
positiveButton.setOnClickListener(v -> {
dialog.dismiss();
selectPassword();
});
}
}.start();
});
Comment on lines +564 to +585

Dialogs.showSecureDialog(dialog);
}

private class BiometricPromptListener extends BiometricPrompt.AuthenticationCallback {
@Override
public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,24 @@ public static void showBackupsVersioningStrategy(Context context, BackupsVersion
showSecureDialog(alertDialog);
}

public static void showMaxFailedAttemptsPickerDialog(Context context, int currentValue, NumberInputListener listener) {
View view = LayoutInflater.from(context).inflate(R.layout.dialog_number_picker, null);
NumberPicker numberPicker = view.findViewById(R.id.numberPicker);
numberPicker.setMinValue(1);
numberPicker.setMaxValue(100);
numberPicker.setValue(currentValue);
numberPicker.setWrapSelectorWheel(true);

AlertDialog dialog = new MaterialAlertDialogBuilder(context)
.setTitle(R.string.pref_max_failed_attempts_title)
.setView(view)
.setPositiveButton(android.R.string.ok, (dialog1, which) ->
listener.onNumberInputResult(numberPicker.getValue()))
.create();

showSecureDialog(dialog);
}

private static void setImporterHelpText(TextView view, DatabaseImporter.Definition definition, boolean isDirect) {
if (isDirect) {
view.setText(view.getResources().getString(R.string.importer_help_direct, definition.getName()));
Expand Down
Loading