Files
DankMaterialShell/quickshell/Modules/Lock/Pam.qml
T
hkoeck 10eb5f6ec3 lock: show the PAM prompt and track the conversation state (#3691)
Pam.qml holds the prompt text PAM asked for and separates "a response is
in flight" from "the conversation is open", so the UI stops treating an
open conversation as busy. The lock screen shows the prompt instead of a
hardcoded "Password", and clears the field when a new prompt arrives.
2026-10-12 11:16:18 -04:00

760 lines
24 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import qs.Common
import qs.Services
Scope {
id: root
property bool lockSecured: false
property bool unlockInProgress: false
readonly property alias passwd: passwd
readonly property alias fprint: fprint
readonly property alias u2f: u2f
property string lockMessage
property string state
property string fprintState
property string u2fState
property bool u2fPending: false
property string u2fPendingMode
property string buffer
// the prompt pam asked for, e.g. "PIN: "
property string authPromptText: ""
property bool responseSubmitted: false
property bool awaitingUserInput: false
property bool responseInFlight: false
// the conversation is held open while waiting, so passwd.active is not "busy"
readonly property bool authBusy: passwd.active && root.responseInFlight
// stops a conversation that never prompts from being reopened forever
property int autoRestartCount: 0
property var attemptInfoMessages: []
property bool lockoutAnnouncedThisAttempt: false
signal flashMsg
signal unlockRequested
function resetAuthFlows(): void {
passwd.abort();
fprint.stop();
u2f.abort();
u2fErrorRetry.running = false;
u2fPendingTimeout.running = false;
passwdActiveTimeout.running = false;
unlockRequestTimeout.running = false;
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = "";
root.unlockInProgress = false;
}
function recoverFromAuthStall(newState: string): void {
resetAuthFlows();
root.state = newState;
flashMsg();
stateReset.restart();
fprint.checkAvail();
u2f.checkAvail();
}
// open the conversation without answering it, so the prompt is known before typing
function beginAuthSession(): void {
if (passwd.active || root.unlockInProgress || !root.lockSecured)
return;
if (root.autoRestartCount >= 3)
return;
root.autoRestartCount = root.autoRestartCount + 1;
root.responseSubmitted = false;
root.awaitingUserInput = false;
root.responseInFlight = false;
eagerStart.restart();
}
function submitPassword(): void {
root.responseSubmitted = true;
if (!passwd.active) {
passwd.start();
return;
}
if (passwd.responseRequired) {
root.responseSubmitted = false;
root.awaitingUserInput = false;
root.responseInFlight = true;
passwdActiveTimeout.restart();
passwd.respond(root.buffer);
} else {
// kept and sent when the prompt arrives; a stack that never asks still times out
passwdActiveTimeout.restart();
}
}
function completeUnlock(): void {
if (!root.unlockInProgress) {
root.unlockInProgress = true;
passwd.abort();
fprint.stop();
u2f.abort();
u2fErrorRetry.running = false;
u2fPendingTimeout.running = false;
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = "";
unlockRequestTimeout.restart();
unlockRequested();
}
}
function proceedAfterPrimaryAuth(): void {
if (!root.u2fSuppressedByPrimaryPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) {
u2f.startForSecondFactor();
} else {
completeUnlock();
}
}
function cancelU2fPending(): void {
if (!root.u2fPending)
return;
u2f.abort();
u2fErrorRetry.running = false;
u2fPendingTimeout.running = false;
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = "";
fprint.checkAvail();
}
readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded
readonly property bool fprintSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint)
readonly property bool u2fSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f)
readonly property bool customU2fPamActive: SettingsData.lockU2fPamPath !== "" && customU2fPamWatcher.loaded
FileView {
id: customPamWatcher
path: SettingsData.lockPamPath !== "" ? SettingsData.lockPamPath : ""
printErrors: false
}
FileView {
id: dankshellConfigWatcher
path: "/etc/pam.d/dankshell"
printErrors: false
}
FileView {
id: u2fConfigWatcher
path: "/etc/pam.d/dankshell-u2f"
watchChanges: true
printErrors: false
}
FileView {
id: customU2fPamWatcher
path: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : ""
printErrors: false
}
// Fallback stack written by `dms auth resolve-lock` when no managed
// /etc/pam.d/dankshell exists. See #2789.
readonly property string userPamDir: Paths.strip(Paths.state) + "/pam"
FileView {
id: userPamWatcher
path: root.userPamDir + "/dankshell"
printErrors: false
}
Process {
id: resolveUserPam
command: ["dms", "auth", "resolve-lock", "--quiet"]
running: false
onExited: exitCode => {
if (exitCode === 0)
userPamWatcher.reload();
}
}
function ensureUserPamConfig(): void {
if (SettingsData.lockPamExternallyManaged || resolveUserPam.running)
return;
resolveUserPam.running = true;
}
Component.onCompleted: ensureUserPamConfig()
PamContext {
id: passwd
config: {
if (root.customPamActive)
return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1);
if (SettingsData.lockPamExternallyManaged)
return "login";
if (dankshellConfigWatcher.loaded)
return "dankshell";
if (userPamWatcher.loaded)
return "dankshell";
return "login";
}
configDirectory: {
if (root.customPamActive) {
const idx = SettingsData.lockPamPath.lastIndexOf("/");
return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/";
}
if (SettingsData.lockPamExternallyManaged)
return "/etc/pam.d";
if (dankshellConfigWatcher.loaded)
return "/etc/pam.d";
if (userPamWatcher.loaded)
return root.userPamDir;
return Quickshell.shellDir + "/assets/pam";
}
onMessageChanged: {
// collected by position, not text, so it works in any locale
if (message.length > 0 && !responseRequired)
root.attemptInfoMessages = root.attemptInfoMessages.concat([message]);
}
// not responseRequiredChanged: that only fires on change, so two prompts in
// a row leave the second unhandled
onPamMessage: {
if (!responseRequired)
return;
root.authPromptText = (message || "").replace(/:\s*$/, "").trim();
root.autoRestartCount = 0;
const notice = root.attemptInfoMessages.filter(m => m !== message);
if (notice.length > 0) {
root.lockMessage = notice.join("\n");
root.lockoutAnnouncedThisAttempt = true;
}
root.attemptInfoMessages = [];
// each prompt needs its own submission, or the second gets the first answer
if (root.responseSubmitted) {
root.responseSubmitted = false;
root.awaitingUserInput = false;
root.responseInFlight = true;
respond(root.buffer);
} else {
root.awaitingUserInput = true;
root.responseInFlight = false;
passwdActiveTimeout.running = false;
}
}
onCompleted: res => {
// requisite preauth can lock without ever prompting; surface it here too
if (!root.lockoutAnnouncedThisAttempt) {
if (root.attemptInfoMessages.length > 0) {
root.lockMessage = root.attemptInfoMessages.join("\n");
root.lockoutAnnouncedThisAttempt = true;
} else {
root.lockMessage = "";
}
root.attemptInfoMessages = [];
}
if (res === PamResult.Success) {
if (!root.unlockInProgress) {
fprint.stop();
root.proceedAfterPrimaryAuth();
}
return;
}
unlockRequestTimeout.running = false;
root.unlockInProgress = false;
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = "";
u2fPendingTimeout.running = false;
u2f.abort();
if (res === PamResult.Error)
root.state = "error";
else if (res === PamResult.MaxTries)
root.state = "max";
else if (res === PamResult.Failed)
root.state = "fail";
root.responseSubmitted = false;
root.awaitingUserInput = false;
root.responseInFlight = false;
if (res !== PamResult.MaxTries)
root.beginAuthSession();
root.flashMsg();
stateReset.restart();
}
}
Connections {
target: passwd
function onActiveChanged() {
if (passwd.active) {
root.attemptInfoMessages = [];
root.lockoutAnnouncedThisAttempt = false;
root.authPromptText = "";
if (root.responseSubmitted)
passwdActiveTimeout.restart();
else
passwdActiveTimeout.running = false;
} else {
passwdActiveTimeout.running = false;
}
}
}
PamContext {
id: fprint
readonly property bool available: SettingsData.lockFingerprintReady
property int tries: 0
property int errorTries: 0
property double attemptStartedAt: 0
property bool completedDuringStart: false
readonly property int maxErrorTries: 200
readonly property int daemonIdleExitMs: 30000
// sessionTimeoutMs mirrors `timeout=` in assets/pam/fprint
readonly property int sessionTimeoutMs: 90000
readonly property int verifyStartSlackMs: 10000
readonly property bool retrying: errorRetry.running
readonly property int retryInterval: errorRetry.interval
readonly property bool allowed: available && SettingsData.enableFprint && root.lockSecured && !root.fprintSuppressedByPrimaryPam && !root.unlockInProgress && !root.u2fPending && !SessionService.preparingForSleep && !IdleService.monitorsOff && tries < SettingsData.maxFprintTries
readonly property string status: {
if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam)
return "disabled";
if (tries >= SettingsData.maxFprintTries)
return "max";
if (!allowed)
return "paused";
if (active)
return "active";
if (retrying)
return "retrying";
return errorTries >= maxErrorTries ? "stopped" : "idle";
}
function stop(): void {
errorRetry.stop();
attemptStartedAt = 0;
abort();
}
// pam_fprintd reports an expired timeout as PAM_AUTHINFO_UNAVAIL, the same
// result a device fault gives, and its "Verification timed out" message is
// translated, so age is the signal we can rely on. Its timeout runs from the
// verify rather than from the claim, which is why the age is measured from
// the message announcing the verify: a slow claim then cannot make a fault
// look old enough, and a session that never reached a verify never qualifies.
// That message lands after pam_fprintd has already armed its deadline, so the
// age read here runs short of the real timeout and the slack makes it up. The
// bias is deliberate: a timeout misread as a fault parks the reader behind the
// backoff, while a fault misread as a timeout costs one extra retry.
function attemptSettled(): bool {
return attemptStartedAt > 0 && Date.now() - attemptStartedAt >= sessionTimeoutMs - verifyStartSlackMs;
}
function checkAvail(): void {
if (!allowed) {
stop();
return;
}
if (!active && !retrying)
startAttempt();
}
function startAttempt(): void {
if (!allowed || errorTries >= maxErrorTries) {
stop();
return;
}
if (active)
return;
errorRetry.stop();
if (root.fprintState === "error")
root.fprintState = "";
attemptStartedAt = 0;
completedDuringStart = false;
if (start())
return;
if (!completedDuringStart)
scheduleErrorRetry();
}
function scheduleErrorRetry(): void {
stop();
if (!allowed)
return;
errorTries++;
root.fprintState = "error";
if (errorTries === 1)
root.flashMsg();
fprintStateReset.restart();
if (errorTries < maxErrorTries)
errorRetry.restart();
}
function recover(): void {
if (!allowed)
return;
errorTries = 0;
if (!active)
startAttempt();
}
onAllowedChanged: {
if (!allowed)
stop();
else
Qt.callLater(checkAvail);
}
config: "fprint"
configDirectory: Quickshell.shellDir + "/assets/pam"
onPamMessage: {
if (attemptStartedAt === 0)
attemptStartedAt = Date.now();
}
onCompleted: res => {
completedDuringStart = true;
if (!allowed)
return;
switch (res) {
case PamResult.Success:
stop();
passwd.abort();
root.proceedAfterPrimaryAuth();
return;
case PamResult.Error:
if (attemptSettled()) {
stop();
errorTries = 0;
startAttempt();
return;
}
scheduleErrorRetry();
return;
case PamResult.Failed:
case PamResult.MaxTries:
stop();
tries++;
errorTries = 0;
root.fprintState = tries < SettingsData.maxFprintTries ? "fail" : "max";
root.flashMsg();
fprintStateReset.restart();
startAttempt();
return;
}
}
}
PamContext {
id: u2f
property bool available: SettingsData.lockU2fReady
function checkAvail(): void {
if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByPrimaryPam) {
abort();
return;
}
if (SettingsData.u2fMode === "or")
abort();
}
function startForSecondFactor(): void {
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam) {
root.completeUnlock();
return;
}
abort();
root.u2fPending = true;
root.u2fPendingMode = "and";
root.u2fState = "";
u2fPendingTimeout.restart();
start();
}
function startForAlternativeAuth(): void {
if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || root.authBusy || active)
return;
abort();
root.u2fPending = true;
root.u2fPendingMode = "or";
root.u2fState = "";
u2fPendingTimeout.restart();
start();
}
config: {
if (root.customU2fPamActive)
return SettingsData.lockU2fPamPath.slice(SettingsData.lockU2fPamPath.lastIndexOf("/") + 1);
return u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f";
}
configDirectory: {
if (root.customU2fPamActive) {
const idx = SettingsData.lockU2fPamPath.lastIndexOf("/");
return idx > 0 ? SettingsData.lockU2fPamPath.slice(0, idx) : "/";
}
return u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam";
}
onMessageChanged: {
if (message.toLowerCase().includes("touch"))
root.u2fState = "waiting";
}
onCompleted: res => {
if (!available || root.unlockInProgress)
return;
if (res === PamResult.Success) {
root.completeUnlock();
return;
}
if (res === PamResult.Error || res === PamResult.MaxTries || res === PamResult.Failed) {
abort();
if (root.u2fPending) {
if (root.u2fPendingMode === "or") {
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = root.u2fState === "waiting" ? "" : "insert";
u2fPendingTimeout.running = false;
fprint.checkAvail();
return;
}
if (root.u2fState === "waiting") {
// AND mode: device was found but auth failed → back to password
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = "";
fprint.checkAvail();
} else {
// AND mode: no device found → keep pending, show "Insert...", retry
root.u2fState = "insert";
u2fErrorRetry.restart();
}
} else {
root.u2fState = "insert";
}
}
}
}
Timer {
id: errorRetry
// A device wedged by a suspend mid-verify only comes back when fprintd
// restarts, and fprintd only exits after its idle timeout with no client
// attached, so the second retry outlasts that instead of climbing to it.
interval: {
if (fprint.errorTries <= 1)
return 1500;
if (fprint.errorTries === 2)
return fprint.daemonIdleExitMs + 5000;
return 60000;
}
onTriggered: fprint.startAttempt()
}
readonly property bool awaitingActivityRetry: fprint.errorTries > 0 && !fprint.retrying && !fprint.active
function retryFprintOnActivity(): void {
if (root.awaitingActivityRetry)
fprint.recover();
}
Connections {
target: SessionService
function onSessionResumed() {
fprint.recover();
}
function onLidOpened() {
fprint.recover();
}
}
Timer {
id: u2fErrorRetry
interval: 800
onTriggered: u2f.start()
}
Timer {
id: u2fPendingTimeout
interval: 30000
onTriggered: root.cancelU2fPending()
}
Timer {
id: eagerStart
interval: 50
onTriggered: {
if (!passwd.active && root.lockSecured && !root.unlockInProgress)
passwd.start();
}
}
Timer {
id: passwdActiveTimeout
interval: 15000
onTriggered: {
if (passwd.active)
root.recoverFromAuthStall("error");
}
}
Timer {
id: unlockRequestTimeout
interval: 8000
onTriggered: {
if (root.unlockInProgress)
root.recoverFromAuthStall("error");
}
}
Timer {
id: stateReset
interval: 4000
onTriggered: {
if (root.state !== "max")
root.state = "";
}
}
Timer {
id: fprintStateReset
interval: 4000
onTriggered: {
if (root.fprintState !== "max")
root.fprintState = "";
}
}
onLockSecuredChanged: {
if (!lockSecured) {
root.resetAuthFlows();
return;
}
root.state = "";
root.fprintState = "";
root.u2fState = "";
root.u2fPending = false;
root.u2fPendingMode = "";
root.lockMessage = "";
root.attemptInfoMessages = [];
root.lockoutAnnouncedThisAttempt = false;
root.resetAuthFlows();
root.responseSubmitted = false;
root.awaitingUserInput = false;
root.autoRestartCount = 0;
root.beginAuthSession();
fprint.tries = 0;
fprint.errorTries = 0;
if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded)
ensureUserPamConfig();
// FileView cannot watch a path that does not exist yet; re-read so a
// dedicated service created after startup is used on the next lock.
u2fConfigWatcher.reload();
fprint.checkAvail();
u2f.checkAvail();
}
Connections {
target: SettingsData
function onEnableFprintChanged(): void {
if (SettingsData.enableFprint)
fprint.recover();
else
fprint.checkAvail();
}
function onLockFingerprintReadyChanged(): void {
if (SettingsData.lockFingerprintReady)
fprint.recover();
else
fprint.checkAvail();
}
function onEnableU2fChanged(): void {
u2f.checkAvail();
}
function onLockU2fReadyChanged(): void {
u2f.checkAvail();
}
function onLockPamPathChanged(): void {
fprint.checkAvail();
u2f.checkAvail();
}
function onLockPamInlineFprintChanged(): void {
fprint.checkAvail();
}
function onLockPamInlineU2fChanged(): void {
u2f.checkAvail();
}
function onLockPamExternallyManagedChanged(): void {
root.resetAuthFlows();
if (!SettingsData.lockPamExternallyManaged)
root.ensureUserPamConfig();
fprint.checkAvail();
u2f.checkAvail();
}
function onLockU2fPamPathChanged(): void {
u2f.abort();
u2f.checkAvail();
}
function onU2fModeChanged(): void {
if (root.lockSecured) {
u2f.abort();
u2fErrorRetry.running = false;
u2fPendingTimeout.running = false;
unlockRequestTimeout.running = false;
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = "";
u2f.checkAvail();
}
}
}
}