Skip to content
Closed
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
31 changes: 22 additions & 9 deletions ryoku/lockscreen/qylock/README.ryoku.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 20 additions & 30 deletions ryoku/lockscreen/qylock/quickshell-lockscreen/assets/pam/ryoku-lock
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
176 changes: 112 additions & 64 deletions ryoku/lockscreen/qylock/quickshell-lockscreen/shim/SddmShim.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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"]); }
Expand Down Expand Up @@ -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 <lock_dir>/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;
Expand All @@ -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();
}
}

Expand All @@ -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();
}
Expand All @@ -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: {
Expand Down
Loading