From 3b462deb8d4162cc88ad5419c2e7fd4ef76ec00f Mon Sep 17 00:00:00 2001 From: natxobsd Date: Tue, 22 Sep 2026 19:20:44 +0200 Subject: [PATCH] fix: run lockscreen fingerprint and password as parallel PAM conversations A single PAM conversation cannot host both methods: the stack is serialised by design (pam_fprintd(8) LIMITATIONS), so the password prompt starves the sensor. With the stock module the default 30s scan timeout falls through to pam_unix, the conversation stays alive waiting for a typed key, the QML sees pam.active and never re-arms the scan (touch stops working), and a password typed during the scan is stashed until the prompt finally arrives (up to 30s later). Split into two PamContexts started at arm time: - ryoku-lock: fingerprint only, pam_fprintd.so timeout=-1, so the scan lives for the whole lock; max-tries (3) still ends it on misreads and the shim re-arms after 1s - ryoku-lock-pw: password only, prompt pending from lock time, so a typed key answers immediately with no stash First Success unlocks and aborts the other (guarded against double fire); a wrong password shakes the field and re-prompts without disturbing the sensor conversation. Verified on v0.63.1-beta.19 with debug PAM logs (since removed): touch at 3s, touch still matching after 58s of scanning, password obtained in the same second as the lock. qmllint clean. --- ryoku/lockscreen/qylock/README.ryoku.md | 31 ++- .../assets/pam/ryoku-lock | 50 ++--- .../assets/pam/ryoku-lock-pw | 20 ++ .../quickshell-lockscreen/shim/SddmShim.qml | 176 +++++++++++------- 4 files changed, 174 insertions(+), 103 deletions(-) create mode 100644 ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock-pw diff --git a/ryoku/lockscreen/qylock/README.ryoku.md b/ryoku/lockscreen/qylock/README.ryoku.md index 1aeefc1559..061b6a99fd 100644 --- a/ryoku/lockscreen/qylock/README.ryoku.md +++ b/ryoku/lockscreen/qylock/README.ryoku.md @@ -13,12 +13,25 @@ The vendored core skin carries `themes/clockwork/orbital/preview.gif`, the dark-mode segment of upstream `Assets/clockwork.gif`. Optional skins and their catalogue previews are owned by `ryostore`; they are not duplicated here. -The in-session shim diverges from upstream in one place to keep the lock usable -with every skin: `quickshell-lockscreen/shim/SddmShim.qml` (plus the matching -`keyboard` export in `lock_shell.qml`). Upstream omits `sddm.hostName`, so every -skin's `isQuickshell` test is true; skins like `material-you` and `nothing` gate -login and power behind `!isQuickshell`, leaving their password field, reboot, and -shutdown dead under the in-session lock. The shim now reports a real `hostName` -(making `isQuickshell` false), implements `sddm.suspend()`, and exposes SDDM's -`keyboard` object (skins assign `keyboard.numLock`). Everything else is upstream -verbatim. +The in-session shim diverges from upstream in two places: + +1. **Skin compatibility** — `quickshell-lockscreen/shim/SddmShim.qml` (plus the + matching `keyboard` export in `lock_shell.qml`). Upstream omits + `sddm.hostName`, so every skin's `isQuickshell` test is true; skins like + `material-you` and `nothing` gate login and power behind `!isQuickshell`, + leaving their password field, reboot, and shutdown dead under the in-session + lock. The shim now reports a real `hostName` (making `isQuickshell` false), + implements `sddm.suspend()`, and exposes SDDM's `keyboard` object (skins + assign `keyboard.numLock`). + +2. **Parallel PAM conversations** — `shim/SddmShim.qml` runs two `PamContext`s, + backed by two services in `assets/pam/`: `ryoku-lock` (fingerprint, stock + `pam_fprintd.so timeout=-1`) and `ryoku-lock-pw` (password, prompt pending + from lock time); the first `Success` unlocks and aborts the other. A single + conversation cannot host both: the PAM stack is serialised by design (see + the LIMITATIONS section of `pam_fprintd(8)`), so the password prompt starves + the sensor — the stock 30s scan timeout falls through to `pam_unix`, the + conversation stays alive waiting for a typed key, the scan never re-arms, and + a password typed mid-sit is only flushed when the prompt finally arrives. + +Everything else is upstream verbatim. diff --git a/ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock b/ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock index 2326132850..c2b7461479 100644 --- a/ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock +++ b/ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock @@ -1,40 +1,30 @@ #%PAM-1.0 -# Ryoku in-session lock PAM service +# Ryoku in-session lock — FINGERPRINT conversation # -# This is the PAM stack for the Ryoku lock screen's fingerprint unlock. -# It lives in the lock's own assets/pam/ directory and is loaded by -# PamContext.configDirectory, so NO root edit of /etc/pam.d is needed. +# One of the lock screen's TWO parallel PAM conversations (see +# ryoku-lock-pw for the password one). Loaded by PamContext.configDirectory +# from the lock's own assets/pam/, so NO root edit of /etc/pam.d is needed. # -# HOW THE GROSSHACK WORKS: -# pam_fprintd_grosshack.so is a modified version of pam_fprintd that -# forks fprintd-verify at the START of the PAM conversation. This means: +# WHY TWO FILES: pam_fprintd's man page is explicit — the PAM stack is +# serialised by design, so parallel methods (fingerprint + password) must +# run in separate conversations, "the way multiple authentication methods +# are made available to users of gdm". In a single conversation the +# password prompt starved the sensor: the default 30s scan timeout fell +# through to pam_unix, the conversation stayed alive waiting for a typed +# key, and the lock never re-armed the scan (touch stopped working). # -# 1. The fingerprint sensor begins scanning immediately -# 2. pam_unix.so simultaneously waits for a typed password -# 3. The FIRST successful auth (touch OR type) wins -# 4. Both paths race in the same PAM conversation -# -# This is the same mechanism used in /etc/pam.d/sddm, so the behavior -# at the lock screen matches the sign-in screen exactly. -# -# WHY INLINE (not include): -# This file is loaded from the lock's assets/pam/ confdir, NOT from -# /etc/pam.d/. An `include system-login` would resolve against this -# confdir and fail. The inline tail mirrors the module list the -# bundled DMS lock ships for its lock services. +# timeout=-1: keep scanning for the whole lock instead of giving up after +# the default 30s; max-tries (default 3) still ends the conversation on +# repeated misreads so the UI can flash "Not recognized" and re-arm. # # MODULE STACK: -# auth sufficient pam_fprintd_grosshack.so <- touch-to-unlock -# auth sufficient pam_unix.so <- typed password -# auth required pam_env.so <- load env -# auth required pam_deny.so <- deny if neither matched -# account required pam_unix.so <- account validation -# password required pam_deny.so <- no password changes -# session required pam_unix.so <- session setup +# auth sufficient pam_fprintd.so timeout=-1 <- touch-to-unlock +# auth required pam_deny.so <- 3 misreads ⇒ fail ⇒ re-arm +# account required pam_unix.so <- account validation +# password required pam_deny.so <- no password changes +# session required pam_unix.so <- session setup -auth sufficient pam_fprintd_grosshack.so -auth sufficient pam_unix.so try_first_pass nullok -auth required pam_env.so +auth sufficient pam_fprintd.so timeout=-1 auth required pam_deny.so account required pam_unix.so password required pam_deny.so diff --git a/ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock-pw b/ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock-pw new file mode 100644 index 0000000000..66754924bf --- /dev/null +++ b/ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock-pw @@ -0,0 +1,20 @@ +#%PAM-1.0 +# Ryoku in-session lock — PASSWORD conversation +# +# Second of the lock screen's TWO parallel PAM conversations (see +# ryoku-lock for the fingerprint one). pam_fprintd's man page requires +# separate conversations for parallel methods; this one does nothing but +# validate the typed password, and its prompt is pending from lock time +# so a typed key is answered instantly instead of waiting out the scan. +# +# MODULE STACK: +# auth required pam_unix.so nullok <- typed password (empty allowed +# only if the account has none) +# account required pam_unix.so <- account validation +# password required pam_deny.so <- no password changes +# session required pam_unix.so <- session setup + +auth required pam_unix.so nullok +account required pam_unix.so +password required pam_deny.so +session required pam_unix.so diff --git a/ryoku/lockscreen/qylock/quickshell-lockscreen/shim/SddmShim.qml b/ryoku/lockscreen/qylock/quickshell-lockscreen/shim/SddmShim.qml index f02e05f299..81852badc3 100644 --- a/ryoku/lockscreen/qylock/quickshell-lockscreen/shim/SddmShim.qml +++ b/ryoku/lockscreen/qylock/quickshell-lockscreen/shim/SddmShim.qml @@ -4,14 +4,18 @@ // suspend, userModel, sessionModel, keyboard) so themes work unchanged, // plus fingerprint state properties the theme reads for the sensor hint. // -// Fingerprint flow: +// Auth flow — TWO parallel PAM conversations (pam_fprintd's man page is +// explicit: PAM stacks are serialised by design, so parallel methods need +// separate conversations — "the way multiple authentication methods are +// made available to users of gdm"): // 1. lock_shell.qml sets armWhenReady = true once WlSessionLock.secure // 2. The shim probes fprintd-list + ~/.config/qylock/fingerprint -// 3. Stale fprintd-verify processes are cleared, then a PAM conversation -// starts against the ryoku-lock service (grosshack pair) -// 4. grosshack forks fprintd-verify at conversation start: the sensor -// scans while pam_unix waits for a password; first success wins -// 5. A failed scan re-arms after 1s +// 3. Stale fprintd-verify processes are cleared, then BOTH start: +// pamFp -> ryoku-lock (fingerprint; timeout=-1 scans until match) +// pamPw -> ryoku-lock-pw (typed password; prompt pending from t0) +// 4. First conversation to succeed unlocks and aborts the other +// 5. A failed scan (3 misreads) re-arms after 1s; a wrong password +// re-prompts immediately — neither method starves the other import QtQuick import Quickshell @@ -40,6 +44,7 @@ Item { property bool armWhenReady: false // lock surface is secured, arm now property bool armPending: false // an armPrep run is in flight property bool fpTyped: false // a key was fed to the conversation + property bool unlocked: false // a conversation already unlocked; guards double-fire // arm the moment the lock secures; probes alone race and lose it. onArmWhenReadyChanged: { @@ -216,30 +221,27 @@ Item { property bool fingerprintUnlock: shim.fingerprintUnlock // login() is called by the theme when the user submits a password. - // If a grosshack conversation is already active (the sensor is live), - // the typed key is fed into it instead of starting a new conversation. + // The key always goes to the dedicated password conversation + // (pamPw): respond at once if its prompt is up, stash it if the + // prompt has not arrived yet (onResponseRequiredChanged feeds it), + // or start the conversation if it died. function login(user, password, sessionIndex) { - pam.user = user; - if (pam.active) { - // Already inside an armed grosshack conversation: feed this - // key instead of restarting the scan. The open prompt may - // already be waiting, so respond immediately; otherwise stash - // it and onResponseRequiredChanged sends it on arrival. - if (pam.responseRequired) { + pamPw.user = user; + if (pamPw.active) { + if (pamPw.responseRequired) { shim.fpTyped = true; - pam.respond(password); - pam.pendingPassword = ""; + pamPw.respond(password); + pamPw.pendingPassword = ""; } else { - pam.pendingPassword = password; + pamPw.pendingPassword = password; } return; } shim.fpTyped = false; - shim.fingerprintState = "idle"; - pam.pendingPassword = password; + pamPw.pendingPassword = password; if (password === "" && !shim.fingerprintReady) return; - pam.start(); + pamPw.start(); } function reboot() { Quickshell.execDetached(["bash", "-c", "if [ -d /run/systemd/system ]; then systemctl reboot; else loginctl reboot; fi"]); } @@ -336,27 +338,56 @@ Item { } } - // ── PAM conversation ──────────────────────────────────────────────────── - // The core of the fingerprint unlock: a single PamContext using the - // ryoku-lock PAM service. This service contains the grosshack pair: - // - pam_fprintd_grosshack.so forks fprintd-verify at conversation start - // - pam_unix.so waits for a typed password - // - Either success unlocks within the SAME conversation - // - // The config is a relative path ("ryoku-lock"), resolved against - // configDirectory which points to /assets/pam/. This means - // no root edit of /etc/pam.d is needed -- the PAM service ships with - // the lock screen itself. + // ── PAM conversations ──────────────────────────────────────────────────── + // TWO independent conversations race for the unlock — pam_fprintd's man + // page: serialised stacks ⇒ separate conversations for parallel methods. + // configDirectory is the lock's own assets/pam/, so no root edit of + // /etc/pam.d is needed — both services ship with the lock screen itself. PamContext { - id: pam - property string pendingPassword: "" - + id: pamFp config: "ryoku-lock" configDirectory: Quickshell.shellDir + "/assets/pam" - // When PAM asks for a password (the pam_unix prompt), feed it the - // stashed password if we have one, or mark it as required so the - // theme's login() handler sends it on the next keypress. + // Fingerprint conversation. timeout=-1 (in the service file) keeps + // it scanning for the whole lock; only 3 misreads or an error end + // it, and a failure re-arms after 1s. It never prompts, so it can + // never leave a pending response blocking the result. + onCompleted: (result) => { + if (shim.unlocked) + return; + if (result === PamResult.Success) { + shim.unlocked = true; + // A scan that ended without a typed key means the sensor won. + shim.fingerprintUnlock = !shim.fpTyped; + shim.fingerprintState = "success"; + pamPw.abort(); + shim.sddm.loginSucceeded(); + Quickshell.execDetached(["loginctl", "unlock-session"]); + } else { + // Failed scan: flash "Not recognized" and re-arm in 1s. + // The password conversation keeps its prompt — typing, + // and even a later touch, still work throughout. + shim.fingerprintState = "fail"; + rearmTimer.restart(); + } + } + onError: (error) => { + if (!shim.armWhenReady || shim.unlocked) + return; + shim.fingerprintState = "fail"; + rearmTimer.restart(); + } + } + + PamContext { + id: pamPw + property string pendingPassword: "" + config: "ryoku-lock-pw" + configDirectory: Quickshell.shellDir + "/assets/pam" + + // The password prompt is live from lock time: feed the stashed key + // the moment PAM asks (usually PAM is already asking by the time + // login() stashes it, so the response goes out on the same tick). onResponseRequiredChanged: { if (responseRequired && pendingPassword !== "") { shim.fpTyped = true; @@ -365,35 +396,33 @@ Item { } } - // PAM conversation completed. Success = unlock; failure = re-arm. onCompleted: (result) => { + if (shim.unlocked) + return; if (result === PamResult.Success) { - // A scan that ended without a typed key means the sensor won. - shim.fingerprintUnlock = (shim.fingerprintState === "scanning" && !shim.fpTyped); + shim.unlocked = true; + shim.fingerprintUnlock = false; shim.fingerprintState = "success"; + pamFp.abort(); shim.sddm.loginSucceeded(); Quickshell.execDetached(["loginctl", "unlock-session"]); } else { - shim.fingerprintState = "fail"; + // Wrong password: shake the field — the sensor conversation + // is untouched and keeps scanning — then re-prompt right + // away so the retry answers instantly. shim.fpTyped = false; shim.sddm.loginFailed(); - // Conversation is over; re-arm so the next touch scans again. - rearmTimer.restart(); + if (shim.armWhenReady) + shim.startPw(); } } - - // A conversation can also die without completing -- config missing, - // internal PAM error, subprocess killed. Left unhandled, the theme - // would keep claiming the sensor is listening while no scan exists. onError: (error) => { - if (!shim.armWhenReady) + if (!shim.armWhenReady || shim.unlocked) return; - shim.fingerprintState = "fail"; - // A typed conversation that died must clear the theme's unlock flash. - if (shim.fpTyped || pam.pendingPassword !== "") + if (shim.fpTyped || pamPw.pendingPassword !== "") shim.sddm.loginFailed(); shim.fpTyped = false; - rearmTimer.restart(); + shim.startPw(); } } @@ -402,8 +431,9 @@ Item { id: rearmTimer interval: 1000 onTriggered: { - if (!shim.armWhenReady || pam.active) + if (!shim.armWhenReady || pamFp.active) return; + shim.startPw(); if (shim.fingerprintReady) shim.armFingerprint(); } @@ -412,43 +442,61 @@ Item { // ── fingerprint control functions ─────────────────────────────────────── function armFingerprint() { - if (!shim.fingerprintReady || pam.active || shim.armPending) + if (!shim.fingerprintReady || pamFp.active || shim.armPending) return; shim.armPending = true; armPrepProc.running = true; } function armFingerprintNow() { - if (!shim.fingerprintReady || pam.active) + if (!shim.fingerprintReady || pamFp.active) return; - pam.user = Quickshell.env("USER") || "traveler"; - pam.pendingPassword = ""; - shim.fpTyped = false; + pamFp.user = Quickshell.env("USER") || "traveler"; shim.fingerprintUnlock = false; shim.fingerprintState = "scanning"; // start() returns false when the config dir/file or user cannot be // resolved; surface that instead of failing silently. - var started = pam.start(); + var started = pamFp.start(); if (!started) - console.warn("[fp] pam.start() failed: config=", pam.config, - "dir=", pam.configDirectory, "user=", pam.user); + console.warn("[fp] pamFp.start() failed: config=", pamFp.config, + "dir=", pamFp.configDirectory, "user=", pamFp.user); + shim.startPw(); } // resetAuth: aborts any active PAM conversation and resets state. // Called when the lock surface is unlocked. function resetAuth() { + shim.unlocked = false; shim.fingerprintUnlock = false; shim.fpTyped = false; - pam.abort(); + pamFp.abort(); + pamPw.abort(); if (shim.fingerprintState !== "idle") shim.fingerprintState = "idle"; } function maybeArm() { - if (shim.armWhenReady && shim.fingerprintReady && !pam.active) + if (!shim.armWhenReady) + return; + shim.startPw(); + if (shim.fingerprintReady && !pamFp.active) shim.armFingerprint(); } + // The password conversation lives from lock time until unlock, + // independent of the sensor, so a typed key is answered instantly. + // Idempotent: safe to call from every probe and every re-arm. + function startPw() { + if (!shim.armWhenReady || shim.unlocked || pamPw.active) + return; + pamPw.user = Quickshell.env("USER") || "traveler"; + pamPw.pendingPassword = ""; + var started = pamPw.start(); + if (!started) + console.warn("[fp] pamPw.start() failed: config=", pamPw.config, + "dir=", pamPw.configDirectory, "user=", pamPw.user); + } + // ── initialization ────────────────────────────────────────────────────── onThemePathChanged: loadConfig(themePath) Component.onCompleted: {