mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-29 20:12:32 -04:00
Add local password unlock rate limit to iOS and Android apps (#1824)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user