Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased
* Added reliable lockout detection (`AuthExceptionCode.lockout`/`.lockoutPermanent`) on both Android and iOS, instead of collapsing into `.unknown`.
* Added reliable key-invalidation detection (`AuthExceptionCode.keyInvalidated`) on both platforms — previously Android silently self-healed with no signal, and iOS reported a generic `SecurityError`.

## 6.0.0-dev.5

* android: fix `minifyReleaseWithR8` failing in every app that ships a release
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ enum class AuthenticationError(vararg val code: Int) {
Canceled(BiometricPrompt.ERROR_CANCELED),
Timeout(BiometricPrompt.ERROR_TIMEOUT),
UserCanceled(BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON),

/** Temporary lockout (~30s) after too many failed attempts. Clears on its own. */
Lockout(BiometricPrompt.ERROR_LOCKOUT),

/** Hard lockout after continued failures. Only clears via device credential entry. */
LockoutPermanent(BiometricPrompt.ERROR_LOCKOUT_PERMANENT),

/**
* The Keystore key backing this storage can no longer be used because
* the device's enrolled biometrics changed since it was created. Not a
* [BiometricPrompt] error code — reported directly from a caught
* [KeyPermanentlyInvalidatedException] before any prompt is even shown,
* see [BiometricStoragePlugin.withAuth].
*/
KeyInvalidated(-3),
Unknown(-1),

/** Authentication valid, but unknown */
Expand Down Expand Up @@ -188,11 +203,16 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
} else try {
cipherForMode()
} catch (e: KeyPermanentlyInvalidatedException) {
// TODO should we communicate this to the caller?
logger.warn(e) { "Key was invalidated. removing previous storage and recreating." }
logger.warn(e) { "Key was invalidated. removing previous storage." }
deleteFile()
// if deleting fails, simply throw the second time around.
cipherForMode()
// Report the failure to the caller instead of silently
// regenerating a key and re-prompting for it — the app
// gets to decide how to react, rather than transparently
// eating the fact that its data was just wiped.
resultError(
AuthenticationErrorInfo(AuthenticationError.KeyInvalidated, "Key was invalidated", e)
)
return
}

if (cipher == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,9 @@ class BiometricStorageFile {
}

func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) {
if reportIfBiometryLockedOut(result) {
return
}

guard var query = baseQuery(result) else {
return;
Expand Down Expand Up @@ -369,6 +372,12 @@ class BiometricStorageFile {
}

func write(_ content: String, _ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) {
// No pre-flight lockout check here, unlike read(): writes never
// evaluate the access control at all (Keychain only enforces it later,
// on read), so lockout state has no bearing on whether this succeeds.
// handleOSStatusError's post-hoc check below still applies, in case a
// write ever does fail for an auth-related reason.

guard var query = baseQuery(result) else {
return;
}
Expand Down Expand Up @@ -408,11 +417,86 @@ class BiometricStorageFile {
switch status {
case errSecUserCanceled:
code = "AuthError:UserCanceled"
case errSecAuthFailed:
// Keychain doesn't report a distinct OSStatus for "biometry is
// currently locked out" vs. "this key's access control can never be
// satisfied again (invalidated)" — both surface as the same generic
// errSecAuthFailed, since we let SecItemCopyMatching trigger
// LocalAuthentication internally instead of calling
// LAContext.evaluatePolicy ourselves. isBiometryLockedOut() is the
// one call that can tell them apart (it's the second of two lockout
// checks — see read()'s pre-flight call for the first: this one
// catches the case where this very attempt is what pushed the device
// into lockout, which a check made before the attempt couldn't have
// seen yet). Once lockout is ruled out, errSecAuthFailed is the best
// available signal for invalidation on this platform — Apple gives
// no dedicated flag for it the way Android's
// KeyPermanentlyInvalidatedException does.
//
// Restricting this whole branch to errSecAuthFailed assumes lockout
// (when it happens at all here) never surfaces under a different
// OSStatus. That assumption comes from widely observed behavior of
// SecItemCopyMatching + kSecUseAuthenticationContext, not from an
// Apple doc that enumerates every status a locked-out read can
// return — the same caliber of evidence backing the invalidation
// signal itself, not a stronger one.
code = isBiometryLockedOut() ? "AuthError:LockoutPermanent" : "AuthError:KeyInvalidated"
default:
code = "SecurityError"
}

result(storageError(code, "Error while \(message): \(status): \(errorMessage ?? "Unknown")", nil))
}


/// Checks the device's current biometry state directly via
/// `canEvaluatePolicy` — a synchronous, no-UI, officially documented
/// LocalAuthentication call — rather than inferring lockout from a
/// Keychain `OSStatus`, which is deliberately never trusted here: Apple
/// doesn't guarantee `errSecAuthFailed` (or any other status) means
/// lockout specifically, only that *some* auth-related failure occurred.
/// This is the one and only source of truth this plugin uses for lockout
/// on iOS/macOS, called from both `read()` (pre-flight, before touching
/// Keychain at all) and `handleOSStatusError` (post-hoc, after a failure,
/// to catch lockout triggered by the failing attempt itself).
///
/// iOS/macOS expose only a single biometric lockout state — unlike
/// Android's temporary (ERROR_LOCKOUT) vs. permanent (ERROR_LOCKOUT_PERMANENT)
/// split, biometryLockout here can only be cleared by the user entering
/// their device passcode, never by simply waiting. That's why every
/// caller reports it as "AuthError:LockoutPermanent" rather than a bare
/// lockout: the recovery UX (must enter passcode) matches Android's
/// permanent case, not its temporary one.
private func isBiometryLockedOut() -> Bool {
guard initOptions.authenticationRequired else {
return false
}
var error: NSError?
let policy: LAPolicy = initOptions.darwinBiometricOnly ? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication
// A fresh, throwaway context: reusing `self.context` here would risk
// interacting with cached auth state kept alive for
// darwinTouchIDAuthenticationForceReuseContextDuration, which this
// probe has no business touching.
let probeContext = LAContext()
if probeContext.canEvaluatePolicy(policy, error: &error) {
return false
}
guard let laError = error else {
return false
}
return LAError(_nsError: laError).code == .biometryLockout
}

/// Pre-flight lockout guard for `read()`: if biometry is currently locked
/// out, reports it immediately via `result` and returns `true` so the
/// caller can bail before even attempting Keychain access — no point
/// building a query or (on some OS versions) surfacing a system prompt
/// for an attempt that's guaranteed to fail. See `isBiometryLockedOut`
/// for why this is trustworthy rather than a guess.
private func reportIfBiometryLockedOut(_ result: @escaping StorageCallback) -> Bool {
guard isBiometryLockedOut() else {
return false
}
result(storageError("AuthError:LockoutPermanent", "Biometry is locked out", nil))
return true
}
}
32 changes: 32 additions & 0 deletions lib/src/biometric_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,44 @@ enum AuthExceptionCode {
unknown,
timeout,
linuxAppArmorDenied,

/// Too many failed attempts. Temporary on Android (~30s, clears on its
/// own); iOS/macOS never report this value — see [lockoutPermanent].
lockout,

/// Locked out until the user enters their device credential (PIN/pattern/
/// password on Android, passcode on iOS/macOS). On Android this follows
/// continued failures after [lockout]. iOS/macOS only ever report this
/// value for a biometric lockout — Apple exposes no separate temporary
/// state, since `LAError.biometryLockout` always requires passcode entry
/// to clear, never just a wait.
lockoutPermanent,

/// The key backing this storage can no longer be used because the
/// device's enrolled biometrics changed since it was created (e.g. a
/// fingerprint/face was added or removed). The stored data is
/// unrecoverable; the caller should clear storage and have the user
/// re-write.
///
/// Android detects this with certainty: `KeyPermanentlyInvalidatedException`
/// is a documented, unambiguous signal thrown when the Keystore cipher is
/// initialized, on both read and write.
///
/// iOS/macOS have no equivalent dedicated signal — Keychain collapses this
/// into the same `errSecAuthFailed` used for other non-lockout auth failures,
/// so this is reported whenever that status occurs and [lockoutPermanent]
/// doesn't apply. That makes it the best available signal on iOS/macOS, not a
/// certainty the way the Android case is.
keyInvalidated,
}

const _authErrorCodeMapping = {
'AuthError:UserCanceled': AuthExceptionCode.userCanceled,
'AuthError:Canceled': AuthExceptionCode.canceled,
'AuthError:Timeout': AuthExceptionCode.timeout,
'AuthError:Lockout': AuthExceptionCode.lockout,
'AuthError:LockoutPermanent': AuthExceptionCode.lockoutPermanent,
'AuthError:KeyInvalidated': AuthExceptionCode.keyInvalidated,
};

/// Why a [BiometricStorageException] was raised.
Expand Down