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
78 changes: 68 additions & 10 deletions shell/plugins/lock/Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ Item {
property bool fingerprintAuthenticating: false
property bool passwordPamConfigured: false
property bool fingerprintConfigured: false
// Nobody can touch the sensor while the display is blanked, so the
// fingerprint PAM is aborted for that stretch instead of being retried
// every timeout until the display comes back.
property bool fingerprintSuspended: false
// A reader that fails the moment it is asked -- a backend in a bad way
// rather than a finger that did not match -- would otherwise be asked again
// every retry for the whole lock. Back those attempts off, and forget the
// streak as soon as one lasts long enough to have been a real touch or the
// user turns up at the display.
readonly property int fingerprintRetryBase: 250
readonly property int fingerprintRetryFloor: 2000
readonly property int fingerprintRetryMax: 30000
readonly property int fingerprintInstantFailure: 1000
readonly property int fingerprintRetryDelay: fingerprintFailureStreak === 0
? fingerprintRetryBase
: Math.min(fingerprintRetryMax, fingerprintRetryFloor * Math.pow(2, fingerprintFailureStreak - 1))
property int fingerprintFailureStreak: 0
property double fingerprintStartedAt: 0
property bool previewVisible: false
property string enteredPassword: ""
property string pendingPassword: ""
Expand Down Expand Up @@ -130,6 +148,9 @@ Item {
failedAttempts = 0
authenticatingPassword = false
fingerprintAuthenticating = false
fingerprintSuspended = false
fingerprintFailureStreak = 0
fingerprintStartedAt = 0
fingerprintRetryTimer.stop()
if (passwordPam.active) passwordPam.abort()
if (fingerprintPam.active) fingerprintPam.abort()
Expand Down Expand Up @@ -179,12 +200,50 @@ Item {
root.monitorDpmsKnown = false
if (!wakeProcess.running) wakeProcess.running = true
if (lockRequested) armBlankTimer()
resumeFingerprint()
}

function runBlank() {
root.displaysBlank = true
root.monitorDpmsKnown = false
if (!blankProcess.running) blankProcess.running = true
suspendFingerprint()
}

function suspendFingerprint() {
if (fingerprintSuspended) return

fingerprintSuspended = true
fingerprintRetryTimer.stop()
if (fingerprintPam.active) fingerprintPam.abort()
fingerprintAuthenticating = false
}

function resumeFingerprint() {
resetFingerprintBackoff()
if (!fingerprintSuspended) return

fingerprintSuspended = false
if (lockRequested && fingerprintConfigured) startFingerprint()
}

function resetFingerprintBackoff() {
if (fingerprintFailureStreak === 0) return

fingerprintFailureStreak = 0
if (fingerprintRetryTimer.running) fingerprintRetryTimer.restart()
}

function scheduleFingerprintRetry() {
if (!lockRequested || !fingerprintConfigured || fingerprintSuspended) return

// An attempt no finger had time to reach is the reader failing, not the
// user missing; only those compound.
var attempt = fingerprintStartedAt > 0 ? Date.now() - fingerprintStartedAt : 0
if (attempt >= fingerprintInstantFailure) fingerprintFailureStreak = 0
else if (fingerprintFailureStreak < 16) fingerprintFailureStreak += 1

fingerprintRetryTimer.restart()
}

function screenBlank(screenName) {
Expand Down Expand Up @@ -245,10 +304,11 @@ Item {
}

function startFingerprint() {
if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return
if (fingerprintSuspended || !lockRequested || !sessionLock.secure || !fingerprintConfigured) return
if (fingerprintPam.active || fingerprintAuthenticating) return

fingerprintAuthenticating = true
fingerprintStartedAt = Date.now()
if (!fingerprintPam.start()) {
fingerprintAuthenticating = false
}
Expand All @@ -258,11 +318,8 @@ Item {
fingerprintAuthenticating = false

if (!lockRequested) return
if (result === PamResult.Success) {
finishUnlock()
} else if (fingerprintConfigured) {
fingerprintRetryTimer.restart()
}
if (result === PamResult.Success) finishUnlock()
else scheduleFingerprintRetry()
}

WlSessionLock {
Expand Down Expand Up @@ -390,13 +447,13 @@ Item {

onError: function(error) {
root.fingerprintAuthenticating = false
if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart()
root.scheduleFingerprintRetry()
}
}

Timer {
id: fingerprintRetryTimer
interval: 250
interval: root.fingerprintRetryDelay
repeat: false
onTriggered: root.startFingerprint()
}
Expand Down Expand Up @@ -491,8 +548,8 @@ Item {
return
}
// Only a password check in flight should hold the display up. The
// fingerprint PAM stays armed for the whole lock, so gating on
// `authenticating` here would keep the panel lit until unlock.
// fingerprint PAM stays armed for the whole time the display is up, so
// gating on `authenticating` here would keep the panel lit until unlock.
if (root.lockRequested && !root.authenticatingPassword) root.runBlank()
}
}
Expand Down Expand Up @@ -537,6 +594,7 @@ Item {
// for, so the blank state has to be given up here or a visible lock
// wallpaper stays frozen until the next keypress.
root.displaysBlank = false
root.resumeFingerprint()
root.requestSessionLock()

// A monitor still coming up has no workspace, so cannot answer yet.
Expand Down
106 changes: 106 additions & 0 deletions test/shell.d/lock-blank-fingerprint-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,110 @@ assert(
!/onAuthenticatingChanged:/.test(serviceQml),
'the combined authenticating state no longer drives the blank timer'
)

// Nobody can touch the sensor once the panel is dark, so the reader is not
// polled through the blank: the PAM conversation is dropped and the retry
// timer stopped until the display comes back.
assert(
/function runBlank\(\) \{[\s\S]*?suspendFingerprint\(\)[\s\S]*?\n \}/.test(serviceQml),
'blanking the display suspends the fingerprint'
)

assert(
/function suspendFingerprint\(\) \{[\s\S]*?fingerprintRetryTimer\.stop\(\)[\s\S]*?if \(fingerprintPam\.active\) fingerprintPam\.abort\(\)/.test(serviceQml),
'suspending stops the retry timer and aborts the fingerprint PAM'
)

assert(
/startFingerprint\(\) \{\s*if \(fingerprintSuspended \|\|/.test(serviceQml),
'the fingerprint prompt does not start again while suspended'
)

assert(
/function scheduleFingerprintRetry\(\) \{\s*if \([^)]*fingerprintSuspended\) return/.test(serviceQml),
'an aborted conversation does not re-arm the retry timer while suspended'
)

// The display coming back is the only thing that brings the reader back, and
// it must survive a wake that is itself the first keystroke of a password.
assert(
/function runWake\(\) \{[\s\S]*?resumeFingerprint\(\)[\s\S]*?\n \}/.test(serviceQml),
'waking the display resumes the fingerprint'
)

assert(
/function resumeFingerprint\(\) \{[\s\S]*?if \(lockRequested && fingerprintConfigured\) startFingerprint\(\)/.test(serviceQml),
'resuming only re-arms a lock that still wants a fingerprint'
)

const fingerprintToggles = serviceQml.match(/function (?:suspend|resume)Fingerprint\(\) \{[\s\S]*?\n \}/g) || []

assert(
fingerprintToggles.length === 2,
'the display drives the fingerprint through one suspend and one resume'
)

assert(
fingerprintToggles.every(body => !/[Pp]assword/.test(body)),
'suspending and resuming never touch a password in flight'
)

assert(
/resetAuthenticationState\(\) \{[\s\S]*?fingerprintSuspended = false/.test(serviceQml),
'a fresh lock starts with the fingerprint unsuspended'
)

// A reader that errors the instant it is asked answers faster than a finger
// can arrive, so retrying it at the base interval is a hot loop for as long as
// the lock is up. Only those attempts compound, and the display waking clears
// the streak so a recovered reader is live again straight away.
assert(
/else scheduleFingerprintRetry\(\)/.test(serviceQml) &&
/onError: function\(error\) \{[\s\S]*?root\.scheduleFingerprintRetry\(\)/.test(serviceQml),
'every unsuccessful fingerprint conversation reschedules through the backoff'
)

assert(
!/fingerprintRetryTimer\.restart\(\)/.test(serviceQml.replace(/function (?:reset|schedule)Fingerprint\w+\(\) \{[\s\S]*?\n \}/g, '')),
'nothing re-arms the retry timer behind the backoff'
)

assert(
/interval: root\.fingerprintRetryDelay/.test(serviceQml),
'the retry timer takes its interval from the backoff'
)

const retryDelay = serviceQml.match(/readonly property int fingerprintRetryDelay:[\s\S]*?\n property/)
assert(retryDelay, 'the retry delay is derived from the failure streak')
assert(
/fingerprintFailureStreak === 0\s*\?\s*fingerprintRetryBase/.test(retryDelay[0]),
'a fingerprint that took a real touch to fail is retried at the base interval'
)
assert(
/Math\.min\(fingerprintRetryMax, fingerprintRetryFloor \* Math\.pow\(2, fingerprintFailureStreak - 1\)\)/.test(retryDelay[0]),
'instant failures back off exponentially up to the cap'
)

const backoff = {}
for (const [, name, value] of serviceQml.matchAll(/readonly property int (fingerprintRetry\w+|fingerprintInstantFailure): (\d+)/g)) {
backoff[name] = Number(value)
}

assert(backoff.fingerprintRetryBase === 250, 'the base retry interval is unchanged')
assert(backoff.fingerprintRetryFloor >= 2000, 'a failing reader is left alone for at least a couple of seconds')
assert(backoff.fingerprintRetryMax === 30000, 'the backoff is capped at 30s')
assert(
backoff.fingerprintInstantFailure > 0 && backoff.fingerprintInstantFailure <= backoff.fingerprintRetryFloor,
'an attempt is only instant if it ended sooner than a finger could reach the reader'
)

assert(
/function resumeFingerprint\(\) \{\s*resetFingerprintBackoff\(\)/.test(serviceQml),
'the display waking clears the backoff even when the fingerprint was never suspended'
)

assert(
/resetAuthenticationState\(\) \{[\s\S]*?fingerprintFailureStreak = 0/.test(serviceQml),
'a fresh lock starts with no backoff'
)
JS