Add local password unlock rate limit to iOS and Android apps (#1824)

This commit is contained in:
Leendert de Borst
2026-03-08 13:22:32 +01:00
parent 8fc3708173
commit 4792c71c25
8 changed files with 193 additions and 7 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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<View>(android.R.id.content).setOnApplyWindowInsetsListener { _, insets ->
val systemBarsInsets = insets.systemWindowInsets

View File

@@ -82,4 +82,6 @@
<string name="password_unlock_button">Unlock</string>
<string name="password_unlock_incorrect">Incorrect password. Please try again.</string>
<string name="password_unlock_error">Failed to verify password</string>
<string name="password_unlock_attempts_warning">Incorrect password. You will be logged out if you enter the wrong password %d more times.</string>
<string name="password_unlock_max_attempts_reached">Too many failed unlock attempts. You have been logged out for security reasons.</string>
</resources>

View File

@@ -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);

View File

@@ -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"])
}
)

View File

@@ -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)
}
}
}

View File

@@ -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";