diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt index 78a0af9fa..cd199b510 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/MainActivity.kt @@ -291,6 +291,11 @@ class MainActivity : ReactActivity() { // For authenticateUser(), resolve with false authPromise?.resolve(false) } + net.aliasvault.app.passwordunlock.PasswordUnlockActivity.RESULT_MAX_ATTEMPTS_REACHED -> { + // Max attempts reached - vault has been cleared, reject to trigger logout in React Native + passwordPromise?.reject("MAX_ATTEMPTS_REACHED", "Too many failed unlock attempts", null) + authPromise?.reject("MAX_ATTEMPTS_REACHED", "Too many failed unlock attempts", null) + } else -> { // For showPasswordUnlock(), resolve with null passwordPromise?.resolve(null) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt index f906df7ab..e9851ab2f 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/nativevaultmanager/NativeVaultManager.kt @@ -130,6 +130,11 @@ class NativeVaultManager(reactContext: ReactApplicationContext) : override fun clearSession(promise: Promise) { try { vaultStore.clearSession() + + // Reset password unlock failed attempts counter on logout + val sharedPreferences = reactApplicationContext.getSharedPreferences("aliasvault", android.content.Context.MODE_PRIVATE) + sharedPreferences.edit().remove("password_unlock_failed_attempts").apply() + promise.resolve(null) } catch (e: Exception) { Log.e(TAG, "Error clearing session", e) diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt index 77cc063e7..f53a2d44e 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/passwordunlock/PasswordUnlockActivity.kt @@ -4,6 +4,7 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.app.Activity +import android.content.Context import android.content.Intent import android.os.Bundle import android.text.Editable @@ -19,6 +20,7 @@ import android.widget.ImageButton import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -45,6 +47,9 @@ class PasswordUnlockActivity : AppCompatActivity() { /** Result code for cancelled password unlock. */ const val RESULT_CANCELLED = Activity.RESULT_CANCELED + /** Result code for max attempts reached - user has been logged out. */ + const val RESULT_MAX_ATTEMPTS_REACHED = Activity.RESULT_FIRST_USER + 1 + /** Intent extra key for the encryption key (returned on success). */ const val EXTRA_ENCRYPTION_KEY = "encryption_key" @@ -56,6 +61,15 @@ class PasswordUnlockActivity : AppCompatActivity() { /** Intent extra key for custom button text (optional). */ const val EXTRA_CUSTOM_BUTTON_TEXT = "custom_button_text" + + /** Maximum number of failed password attempts before logout. */ + private const val MAX_FAILED_ATTEMPTS = 10 + + /** Warning threshold for failed attempts. */ + private const val WARNING_THRESHOLD = 5 + + /** SharedPreferences key for failed password attempts counter. */ + private const val PREF_FAILED_ATTEMPTS = "password_unlock_failed_attempts" } private lateinit var vaultStore: VaultStore @@ -74,6 +88,7 @@ class PasswordUnlockActivity : AppCompatActivity() { // State private var isProcessing: Boolean = false private var isShowingError: Boolean = false + private var failedAttempts: Int = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -94,6 +109,10 @@ class PasswordUnlockActivity : AppCompatActivity() { AndroidStorageProvider(this), ) + // Load failed attempts counter + val sharedPreferences = getSharedPreferences("aliasvault", Context.MODE_PRIVATE) + failedAttempts = sharedPreferences.getInt(PREF_FAILED_ATTEMPTS, 0) + // Get custom title/subtitle/buttonText from intent val customTitle = intent.getStringExtra(EXTRA_CUSTOM_TITLE) val customSubtitle = intent.getStringExtra(EXTRA_CUSTOM_SUBTITLE) @@ -253,15 +272,16 @@ class PasswordUnlockActivity : AppCompatActivity() { } if (encryptionKey != null) { - // Success - return encryption key + // Success - reset failed attempts counter and return encryption key + resetFailedAttempts() val resultIntent = Intent().apply { putExtra(EXTRA_ENCRYPTION_KEY, encryptionKey) } setResult(RESULT_SUCCESS, resultIntent) finish() } else { - // Incorrect password - showError(getString(R.string.password_unlock_incorrect)) + // Incorrect password - increment failed attempts + handleFailedAttempt() } } catch (e: Exception) { // Error during verification @@ -324,6 +344,64 @@ class PasswordUnlockActivity : AppCompatActivity() { } } + private fun handleFailedAttempt() { + failedAttempts++ + saveFailedAttempts() + + val remainingAttempts = MAX_FAILED_ATTEMPTS - failedAttempts + + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + // Max attempts reached - logout user + logoutUser() + } else if (failedAttempts >= WARNING_THRESHOLD) { + // Show warning about remaining attempts + val warningMessage = getString(R.string.password_unlock_attempts_warning, remainingAttempts) + showError(warningMessage) + } else { + // Show standard incorrect password error + showError(getString(R.string.password_unlock_incorrect)) + } + } + + private fun saveFailedAttempts() { + val sharedPreferences = getSharedPreferences("aliasvault", Context.MODE_PRIVATE) + sharedPreferences.edit { + putInt(PREF_FAILED_ATTEMPTS, failedAttempts) + } + } + + private fun resetFailedAttempts() { + failedAttempts = 0 + val sharedPreferences = getSharedPreferences("aliasvault", Context.MODE_PRIVATE) + sharedPreferences.edit { + remove(PREF_FAILED_ATTEMPTS) + } + } + + private fun logoutUser() { + CoroutineScope(Dispatchers.Main).launch { + try { + // Clear vault and all session data + withContext(Dispatchers.IO) { + vaultStore.clearVault() + } + + // Show logout message and close activity + showError(getString(R.string.password_unlock_max_attempts_reached)) + + // Delay to let user read the message, then return max attempts result + passwordEditText.postDelayed({ + setResult(RESULT_MAX_ATTEMPTS_REACHED) + finish() + }, 2000) + } catch (e: Exception) { + android.util.Log.e("PasswordUnlockActivity", "Error during logout", e) + setResult(RESULT_MAX_ATTEMPTS_REACHED) + finish() + } + } + } + private fun applyWindowInsets() { findViewById(android.R.id.content).setOnApplyWindowInsetsListener { _, insets -> val systemBarsInsets = insets.systemWindowInsets diff --git a/apps/mobile-app/android/app/src/main/res/values/strings.xml b/apps/mobile-app/android/app/src/main/res/values/strings.xml index 08d409ca2..08025064f 100644 --- a/apps/mobile-app/android/app/src/main/res/values/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values/strings.xml @@ -82,4 +82,6 @@ Unlock Incorrect password. Please try again. Failed to verify password + Incorrect password. You will be logged out if you enter the wrong password %d more times. + Too many failed unlock attempts. You have been logged out for security reasons. \ No newline at end of file diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 1ef8a7c0d..c06c90809 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -135,6 +135,13 @@ export default function UnlockScreen() : React.ReactNode { return; } + // Check if max attempts reached + if (err && typeof err === 'object' && 'code' in err && err.code === 'MAX_ATTEMPTS_REACHED') { + // Max attempts reached - vault has been cleared, force logout + await logoutForced(); + return; + } + console.error('Unlock error:', err); const errorCode = getAppErrorCode(err); @@ -216,6 +223,13 @@ export default function UnlockScreen() : React.ReactNode { return; } + // Check if max attempts reached + if (err && typeof err === 'object' && 'code' in err && err.code === 'MAX_ATTEMPTS_REACHED') { + // Max attempts reached - vault has been cleared, force logout + await logoutForced(); + return; + } + // Try to extract error code from the error const errorCode = getAppErrorCode(err); diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index aa9f79bf5..f4bdfbb25 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -199,6 +199,9 @@ public class VaultManager: NSObject { @objc func clearSession() { vaultStore.clearSession() + + // Reset password unlock failed attempts counter on logout + UserDefaults.standard.removeObject(forKey: "password_unlock_failed_attempts") } /// Clear all vault data including from persisted storage. @@ -876,6 +879,20 @@ public class VaultManager: NSObject { rootVC.dismiss(animated: true) { resolve(nil) } + }, + logoutHandler: { [weak self] in + // Clear vault on max failed attempts + try? self?.vaultStore.clearVault() + + // Throw error to signal max attempts reached to React Native + await MainActor.run { + rootVC.dismiss(animated: true) { + reject("MAX_ATTEMPTS_REACHED", "Too many failed unlock attempts", nil) + } + } + + // Throw to stop further processing in ViewModel + throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Max attempts reached"]) } ) diff --git a/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockViewModel.swift b/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockViewModel.swift index df05663b4..a3fcefa66 100644 --- a/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockViewModel.swift +++ b/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockViewModel.swift @@ -16,19 +16,33 @@ public class PasswordUnlockViewModel: ObservableObject { private let unlockHandler: (String) async throws -> Void private let cancelHandler: () -> Void + private let logoutHandler: (() async throws -> Void)? + + // Brute force protection + private static let maxFailedAttempts = 10 + private static let warningThreshold = 5 + private static let failedAttemptsKey = "password_unlock_failed_attempts" + + @Published private var failedAttempts: Int = 0 + private var isMaxAttemptsReached = false public init( customTitle: String?, customSubtitle: String?, customButtonText: String?, unlockHandler: @escaping (String) async throws -> Void, - cancelHandler: @escaping () -> Void + cancelHandler: @escaping () -> Void, + logoutHandler: (() async throws -> Void)? = nil ) { self.customTitle = customTitle self.customSubtitle = customSubtitle self.customButtonText = customButtonText self.unlockHandler = unlockHandler self.cancelHandler = cancelHandler + self.logoutHandler = logoutHandler + + // Load failed attempts from UserDefaults + self.failedAttempts = UserDefaults.standard.integer(forKey: Self.failedAttemptsKey) } public func unlock() async { @@ -40,15 +54,64 @@ public class PasswordUnlockViewModel: ObservableObject { do { try await unlockHandler(password) + // Success - reset failed attempts + resetFailedAttempts() } catch { - // Show error and clear password + // Handle failed attempt + await handleFailedAttempt() + } + } + + public func cancel() { + // If max attempts was reached, this will be handled in logoutUser + if !isMaxAttemptsReached { + cancelHandler() + } + } + + private func handleFailedAttempt() async { + failedAttempts += 1 + saveFailedAttempts() + + let remainingAttempts = Self.maxFailedAttempts - failedAttempts + + if failedAttempts >= Self.maxFailedAttempts { + // Max attempts reached - logout user + isMaxAttemptsReached = true + self.error = String(localized: "max_attempts_reached", bundle: locBundle) + await logoutUser() + } else if failedAttempts >= Self.warningThreshold { + // Show warning about remaining attempts + let format = String(localized: "attempts_warning", bundle: locBundle) + self.error = String(format: format, remainingAttempts) + self.password = "" + self.isProcessing = false + } else { + // Show standard incorrect password error self.error = String(localized: "incorrect_password", bundle: locBundle) self.password = "" self.isProcessing = false } } - public func cancel() { - cancelHandler() + private func saveFailedAttempts() { + UserDefaults.standard.set(failedAttempts, forKey: Self.failedAttemptsKey) + } + + private func resetFailedAttempts() { + failedAttempts = 0 + UserDefaults.standard.removeObject(forKey: Self.failedAttemptsKey) + } + + private func logoutUser() async { + // Delay to let user read the message + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + // Call logout handler if provided - this will throw MAX_ATTEMPTS_REACHED error + do { + try await logoutHandler?() + } catch { + // Error from logout handler (will be caught by calling code) + } } } diff --git a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings index 559605953..b5529bcb7 100644 --- a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings +++ b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings @@ -72,6 +72,8 @@ "enter_password_to_unlock" = "Enter your master password"; "unlock" = "Unlock"; "incorrect_password" = "Incorrect password. Please try again."; +"attempts_warning" = "Incorrect password. You will be logged out if you enter the wrong password %d more times."; +"max_attempts_reached" = "Too many failed unlock attempts. You have been logged out for security reasons."; /* PIN Setup */ "pin_setup_title" = "Setup PIN";