Back off on repeated app-lock attempts (ios#41, Android half)
Android had the lock and none of the back-off. iOS has counted failed unlocks and imposed a growing wait since ios#41; here every attempt was free, so somebody holding an unlocked phone could work through PINs as fast as the prompt would reappear. Two platforms disagreeing about a security guarantee is how one of them ends up being the one that leaks — the same argument that put the lock on Android in the first place. Same schedule as iOS: three free attempts, then 30 seconds doubling to a five-minute ceiling. Both ends matter. A mistyped PIN or a fingerprint that did not read is ordinary, and making the first slip cost a wait would punish the owner far more often than anybody else; and an unbounded back-off locks somebody out of their own messages permanently, which is worse than what it defends against — no reset, no support desk, and the data is on their phone. 🔴 One real divergence from iOS, and it is a bug the Swift version cannot have. iOS computes `30 * pow(2, steps)` in a Double, which saturates to infinity and still caps correctly. Kotlin shifting a Long does not: `30L shl 60` wraps NEGATIVE, and `minOf(negative, cap)` returns the negative — a "wait" in the past, which reads as no wait at all and silently removes the whole feature at exactly the point somebody is grinding attempts. The steps are clamped before the shift. Proven: removing the clamp turns that test red. 🔴 The refusal happens BEFORE the prompt is shown. Letting the system sheet appear and then discarding the answer would teach somebody that attempts are free, which is the opposite of the point. ⚠️ Only a genuine wrong answer counts. `onAuthenticationFailed` is a fingerprint that did not match; `onAuthenticationError` is a cancel, a platform lockout or a hardware problem. Only the first moves the counter, so dismissing the prompt cannot back the owner into a wait — and the platform's own lockout is not counted on top of ours, because two back-offs stacking produce a wait nobody can predict from either rule. ⚠️ Switching the lock off clears any outstanding wait. Leaving one behind strands somebody behind a countdown for a screen that no longer appears. 7 unit tests. App builds, every JVM suite green. PARITY.md gains a Hardening section listing the three that remain (ios#19, #39, #45). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
775b6e62d0
commit
fffab8e685
@@ -53,10 +53,49 @@ class AppLock(private val context: Context) {
|
||||
private const val PREFS = "flamenet-app-lock"
|
||||
private const val ENABLED = "enabled"
|
||||
private const val GRACE_SECONDS = "grace"
|
||||
private const val FAILURES = "failures"
|
||||
private const val LOCKED_UNTIL = "lockedUntil"
|
||||
|
||||
/** Default: re-arm after a minute away. */
|
||||
const val DEFAULT_GRACE_SECONDS = 60
|
||||
|
||||
/**
|
||||
* Failed attempts before the first wait.
|
||||
*
|
||||
* ⚠️ Three, not one. A mistyped PIN or a fingerprint that did not read
|
||||
* is ordinary, and making the first slip cost a wait would punish the
|
||||
* owner far more often than anybody else.
|
||||
*/
|
||||
const val ATTEMPTS_BEFORE_DELAY = 3
|
||||
|
||||
/**
|
||||
* The longest wait this will ever impose.
|
||||
*
|
||||
* 🔴 **Capped, and it must be.** An unbounded back-off locks somebody
|
||||
* out of their own messages permanently, which is a worse outcome than
|
||||
* the one it defends against — there is no reset, no support desk, and
|
||||
* the data is on their own phone.
|
||||
*/
|
||||
const val MAXIMUM_DELAY_MS = 300_000L
|
||||
|
||||
/**
|
||||
* How long to wait after [failures] consecutive failed unlocks.
|
||||
*
|
||||
* Doubles from 30 seconds and stops at five minutes. The shape matters
|
||||
* less than the two ends: nothing for an ordinary slip, and a ceiling.
|
||||
*
|
||||
* ⚠️ Doubling is done by shifting a Long rather than `Math.pow`, so a
|
||||
* large failure count cannot overflow a Double into infinity on the way
|
||||
* to being capped. Twenty consecutive failures is not a realistic
|
||||
* number and it should still return five minutes.
|
||||
*/
|
||||
fun delayMillis(failures: Int): Long {
|
||||
if (failures < ATTEMPTS_BEFORE_DELAY) return 0L
|
||||
val steps = (failures - ATTEMPTS_BEFORE_DELAY).coerceAtMost(20)
|
||||
val seconds = 30L shl steps
|
||||
return minOf(seconds * 1000L, MAXIMUM_DELAY_MS)
|
||||
}
|
||||
|
||||
private const val AUTHENTICATORS =
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
@@ -72,7 +111,13 @@ class AppLock(private val context: Context) {
|
||||
*/
|
||||
var enabled: Boolean
|
||||
get() = prefs.getBoolean(ENABLED, false)
|
||||
set(value) = prefs.edit().putBoolean(ENABLED, value).apply()
|
||||
set(value) {
|
||||
prefs.edit().putBoolean(ENABLED, value).apply()
|
||||
// Switching the lock off clears any outstanding wait: a person who
|
||||
// turned it off should not still be behind a countdown for a screen
|
||||
// that no longer appears.
|
||||
if (!value) clearFailures()
|
||||
}
|
||||
|
||||
var graceSeconds: Int
|
||||
get() = prefs.getInt(GRACE_SECONDS, DEFAULT_GRACE_SECONDS)
|
||||
@@ -108,6 +153,74 @@ class AppLock(private val context: Context) {
|
||||
|
||||
fun noteUnlocked() { leftAt = null }
|
||||
|
||||
// ---- Backing off (ios#41) -------------------------------------------
|
||||
//
|
||||
// 🔴 **Android had the lock and none of the back-off.** iOS has counted
|
||||
// failed unlocks and imposed a growing wait since ios#41; here every
|
||||
// attempt was free, so somebody holding an unlocked phone could work
|
||||
// through PINs as fast as the prompt would reappear. Two platforms
|
||||
// disagreeing about a security guarantee is how one of them ends up being
|
||||
// the one that leaks — the same argument that put the lock here at all.
|
||||
|
||||
/** Consecutive failures so far. */
|
||||
val failures: Int get() = prefs.getInt(FAILURES, 0)
|
||||
|
||||
/**
|
||||
* When the next attempt is allowed, or null if one is allowed now.
|
||||
*
|
||||
* ⚠️ **Wall-clock, and somebody who can change the device clock can skip
|
||||
* the wait.** Said rather than hidden: this slows a person holding the
|
||||
* phone, and it is not a defence against somebody with the device and the
|
||||
* patience to work at it. What actually protects the messages is the
|
||||
* keystore, exactly as the note at the top of this file says.
|
||||
*/
|
||||
fun waitingUntil(now: Long = System.currentTimeMillis()): Long? {
|
||||
val until = prefs.getLong(LOCKED_UNTIL, 0L)
|
||||
return if (until > now) until else null
|
||||
}
|
||||
|
||||
/** Seconds left to wait, rounded up, or zero. */
|
||||
fun secondsToWait(now: Long = System.currentTimeMillis()): Int {
|
||||
val until = waitingUntil(now) ?: return 0
|
||||
return (((until - now) + 999L) / 1000L).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Only a genuine wrong answer counts. Dismissing the prompt is not an
|
||||
* attempt — counting a cancel would let the owner back themselves into a
|
||||
* wait by changing their mind twice. See the error handling in
|
||||
* [authenticate], which is where that distinction is actually made.
|
||||
*/
|
||||
fun recordFailure(now: Long = System.currentTimeMillis()) {
|
||||
val next = failures + 1
|
||||
val wait = delayMillis(next)
|
||||
prefs.edit().apply {
|
||||
putInt(FAILURES, next)
|
||||
if (wait > 0L) putLong(LOCKED_UNTIL, now + wait)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the failures. On a successful unlock, and when the feature is
|
||||
* switched off — leaving a wait behind on a disabled lock would strand
|
||||
* somebody behind a screen that is no longer supposed to exist.
|
||||
*/
|
||||
fun clearFailures() {
|
||||
prefs.edit().remove(FAILURES).remove(LOCKED_UNTIL).apply()
|
||||
}
|
||||
|
||||
/** How to say the wait, in whichever unit reads naturally. */
|
||||
fun waitMessage(now: Long = System.currentTimeMillis()): String? {
|
||||
val seconds = secondsToWait(now)
|
||||
if (seconds <= 0) return null
|
||||
return if (seconds >= 60) {
|
||||
val minutes = (seconds + 59) / 60
|
||||
"Too many attempts. Try again in $minutes ${if (minutes == 1) "minute" else "minutes"}."
|
||||
} else {
|
||||
"Too many attempts. Try again in $seconds ${if (seconds == 1) "second" else "seconds"}."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask.
|
||||
*
|
||||
@@ -120,13 +233,32 @@ class AppLock(private val context: Context) {
|
||||
onSuccess: () -> Unit,
|
||||
onFailure: (String) -> Unit,
|
||||
) {
|
||||
// 🔴 Refuse BEFORE showing the prompt. Letting the system sheet appear
|
||||
// and then discarding the answer would teach somebody that attempts are
|
||||
// free, which is the opposite of the point.
|
||||
waitMessage()?.let { onFailure(it); return }
|
||||
|
||||
val prompt = BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(activity),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult,
|
||||
) = onSuccess()
|
||||
) {
|
||||
clearFailures()
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **The one callback that means a wrong answer.**
|
||||
* `onAuthenticationFailed` fires for a fingerprint that did not
|
||||
* match; `onAuthenticationError` fires for cancels, lockouts and
|
||||
* hardware problems. Only this moves the counter, which is why
|
||||
* dismissing the prompt cannot back the owner into a wait.
|
||||
*/
|
||||
override fun onAuthenticationFailed() {
|
||||
recordFailure()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(code: Int, message: CharSequence) {
|
||||
// ⚠️ A cancel is not a failure. Reporting "authentication
|
||||
@@ -136,6 +268,9 @@ class AppLock(private val context: Context) {
|
||||
code == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
|
||||
code == BiometricPrompt.ERROR_CANCELED
|
||||
) return
|
||||
// ⚠️ The platform's own lockout is not counted on top of
|
||||
// ours. Two back-offs stacking would produce a wait nobody
|
||||
// can predict from either rule.
|
||||
onFailure(message.toString())
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user