diff --git a/app/src/main/java/com/beemdevelopment/aegis/Preferences.java b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java index 7ccda05fb4..2587977797 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/Preferences.java +++ b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java @@ -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(); + } + @Nullable public BackupResult getAndroidBackupResult() { return getBackupResult(false); diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java index a30c552cf7..caf7f6d771 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java @@ -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; @@ -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; @@ -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); @@ -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()); @@ -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 slots = _slots.findAll(PasswordSlot.class); PasswordSlotDecryptTask.Params params = new PasswordSlotDecryptTask.Params(slots, password); PasswordSlotDecryptTask task = new PasswordSlotDecryptTask(AuthActivity.this, new PasswordDerivationListener()); @@ -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; @@ -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) + ); + } + + @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); @@ -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(); + }); + + Dialogs.showSecureDialog(dialog); + } + private class BiometricPromptListener extends BiometricPrompt.AuthenticationCallback { @Override public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java index 692ba3a546..39c81dd9a1 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java @@ -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())); diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/SecurityPreferencesFragment.java b/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/SecurityPreferencesFragment.java index e1123ca2a6..e0aceeb4dd 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/SecurityPreferencesFragment.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/SecurityPreferencesFragment.java @@ -46,6 +46,8 @@ public class SecurityPreferencesFragment extends PreferencesFragment { private SwitchPreferenceCompat _pinKeyboardPreference; private SwitchPreference _backupPasswordPreference; private Preference _backupPasswordChangePreference; + private SwitchPreferenceCompat _dataWipingPreference; + private Preference _maxFailedAttemptsPreference; @Override public void onResume() { @@ -253,6 +255,26 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { Dialogs.showSetPasswordDialog(requireActivity(), new SetBackupPasswordListener()); return false; }); + + _dataWipingPreference = requirePreference("pref_enable_data_wiping"); + _maxFailedAttemptsPreference = requirePreference("pref_max_failed_attempts"); + _maxFailedAttemptsPreference.setSummary( + getString(R.string.pref_max_failed_attempts_summary, _prefs.getMaxFailedAttemptsBeforeWipe()) + ); + + _maxFailedAttemptsPreference.setOnPreferenceClickListener(preference -> { + Dialogs.showMaxFailedAttemptsPickerDialog( + requireContext(), + _prefs.getMaxFailedAttemptsBeforeWipe(), + number -> { + _prefs.setMaxFailedAttemptsBeforeWipe(number); + _maxFailedAttemptsPreference.setSummary( + getString(R.string.pref_max_failed_attempts_summary, number) + ); + } + ); + return false; + }); } private void updateEncryptionPreferences() { diff --git a/app/src/main/res/drawable/ic_warning_24.xml b/app/src/main/res/drawable/ic_warning_24.xml new file mode 100644 index 0000000000..f96d3ac39d --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_24.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/activity_auth.xml b/app/src/main/res/layout/activity_auth.xml index f547fe8241..6fec310dbe 100644 --- a/app/src/main/res/layout/activity_auth.xml +++ b/app/src/main/res/layout/activity_auth.xml @@ -77,6 +77,16 @@ android:inputType="numberPassword"/> + + Groups Change password Set a new password which you will need to unlock your vault + Enable data wiping + Erase vault data after several successive failed login attempts + Maximum failed login attempts + %1$d attempts No reported events No important events have been reported within the app @@ -232,6 +236,9 @@ Add new entry Couldn\'t unlock vault Incorrect password. Make sure you didn\'t mistype your password. + Final attempt remaining + For security reasons, your vault will be erased after the next failed attempt. + OK (%1$d) Passwords should be identical and non-empty Please select an authentication method Encrypting the vault @@ -623,4 +630,7 @@ %d item selected %d items selected + + Password cannot be empty + %1$d failed attempts. Try again in %2$s diff --git a/app/src/main/res/xml/preferences_security.xml b/app/src/main/res/xml/preferences_security.xml index 9b054e5fbc..f0a756767a 100644 --- a/app/src/main/res/xml/preferences_security.xml +++ b/app/src/main/res/xml/preferences_security.xml @@ -81,6 +81,19 @@ android:summary="@string/pref_auto_lock_summary" app:iconSpaceReserved="false"/> + + + +