Watch
1
0
Fork
You've already forked souveraine
0

stepup: authenticate through PAM, not a dead pkcheck branch

StepUpAuth ran `souveraine-pam-auth`, else `pkcheck --action-id
org.souveraine.stepup`. Neither exists: no such binary was ever written
and no polkit action was ever shipped, so both branches failed and every
grant request was silently denied. The header also described passing a
password through SOUVERAINE_STEPUP_PASSWORD, which the code never set.

Now a PamContext against the system souveraine-stepup service, shipped by
cc541d1. The prompt is not owned here: pamMessage raises
promptRequired(family, message, secret) and a surface answers with
respond(). An empty answer is legitimate, not a cancel -- the FPC factor
prompts "Touch and hold" and consumes its ticket on a blank response.

Deliberately unchanged: grant model, TTL, revocation and break-glass all
still live in the shell. Moving them to sessiond is a separate pass.

Authorship: this patch is Rook's work, staged in surfaces/quickshell/
patches/ on 2026-08-12. The patch file carried my identity in its From
header but I did not write it; recording that here rather than wearing it
silently.

Verified: /usr/lib/qt6/bin/qmllint exit 0 against the composed tree, gate
proven able to reject (exit 255 on a deliberately broken control).
Untested: no shell has loaded this and no surface calls respond() yet.
This commit is contained in:
Fimeg 2026-08-13 08:24:07 -04:00
commit 6edebaeca5
2 changed files with 63 additions and 258 deletions

View file

@ -26,15 +26,15 @@
// - 30-second Timer -> expire stale grants
//
// The PAM service file `/etc/pam.d/souveraine-stepup` is NOT shipped by the
// shell it is root-owned system config. Until a native PAM binary lands, the
// authentication flow falls through to polkit (pkexec/pkcheck) as a backend.
// The Process passes the password via the SOUVERAINE_STEPUP_PASSWORD
// environment variable, matching LockScreen.qml's unlockKeyring pattern.
// shell it is root-owned system config, delivered by the souveraine package.
// Without it every request refuses at start() rather than falling through to
// something weaker.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import qs
import qs.services
import qs.modules.common
@ -71,6 +71,11 @@ Singleton {
signal grantExpired(string actionFamily)
signal grantRevoked(string actionFamily)
// PAM needs an answer. `secret` is false for prompts that should be echoed.
// An empty respond() is valid the FPC factor prompts "Touch and hold" and
// consumes its ticket on a blank answer.
signal promptRequired(string actionFamily, string message, bool secret)
// --- Internal state -----------------------------------------------------
// The callback for the in-flight auth request. Only one auth conversation
// runs at a time; a second requestAuth while one is pending is refused.
@ -79,35 +84,36 @@ Singleton {
property var _pendingCallback: null
property string _pendingFamily: ""
// --- PAM / polkit process -----------------------------------------------
// The helper binary `souveraine-pam-auth` is the preferred backend. It
// reads the password from SOUVERAINE_STEPUP_PASSWORD (environment, not
// argv argv leaks via /proc/PID/cmdline) and calls pam_authenticate
// against the souveraine-stepup PAM service. Exit 0 = authenticated,
// exit 1 = denied, exit 2 = PAM error.
// --- PAM conversation ---------------------------------------------------
// A second, narrower PamContext than the lock surface's (doctrine §3),
// pointed at the system souveraine-stepup service. It never unlocks the
// session. Same mechanism LockContext already uses for fprintd.conf.
//
// Until that binary exists on the target machine, the fallback is
// pkcheck(1) against org.souveraine.stepup, which triggers the polkit
// agent's password dialog. pkcheck does not accept a password on stdin;
// the polkit agent owns the prompt. This means the fallback flow is:
// 1. pkcheck --process $$ --action-id org.souveraine.stepup
// 2. polkit agent prompts the user
// 3. pkcheck exits 0 (authorized) or 1 (denied / cancelled)
//
// The Process runs one auth at a time. If a second request arrives while
// one is in flight, it is refused with { ok: false, reason: "auth in
// progress" }.
Process {
id: authProc
// The prompt is not owned here: pamMessage raises promptRequired and a
// surface answers with respond(). One conversation at a time, which is
// PAM's own model and the reason a second requestAuth is refused.
PamContext {
id: stepUpPam
config: "souveraine-stepup"
property string activeFamily: ""
onExited: (exitCode, exitStatus) => {
const family = authProc.activeFamily;
authProc.activeFamily = "";
onPamMessage: {
if (stepUpPam.responseRequired) {
root.promptRequired(stepUpPam.activeFamily, stepUpPam.message,
!stepUpPam.responseVisible);
} else if (stepUpPam.message.length > 0) {
console.log(`[step-up] ${stepUpPam.message}`);
}
}
onCompleted: result => {
const family = stepUpPam.activeFamily;
stepUpPam.activeFamily = "";
if (family.length === 0) {
console.log("[step-up] auth process exited with no active family (stale?)");
console.log("[step-up] PAM completed with no active family (stale?)");
return;
}
@ -115,7 +121,7 @@ Singleton {
root._pendingCallback = null;
root._pendingFamily = "";
if (exitCode === 0) {
if (result === PamResult.Success) {
root._mintGrant(family);
root.authSucceeded(family);
console.log(`[step-up] auth succeeded for ${family}`);
@ -123,7 +129,7 @@ Singleton {
} else {
root._clearGrant(family);
root.authFailed(family);
console.log(`[step-up] auth failed for ${family} (exit ${exitCode})`);
console.log(`[step-up] auth failed for ${family} (${PamResult.toString(result)})`);
if (callback) callback(false);
}
}
@ -140,7 +146,7 @@ Singleton {
function requestAuth(actionFamily, callback) {
const family = String(actionFamily || "").trim();
if (!family) return { ok: false, reason: "empty action family" };
if (authProc.running) return { ok: false, reason: "auth in progress" };
if (stepUpPam.active) return { ok: false, reason: "auth in progress" };
// If a valid grant already exists, skip the PAM conversation entirely.
// The caller gets an immediate true and the TTL is not extended this
@ -156,22 +162,36 @@ Singleton {
root._pendingCallback = callback;
root._pendingFamily = family;
// Try the dedicated PAM helper first. If the binary is not installed,
// fall through to pkcheck (polkit). This is a runtime detection, not a
// compile-time choice the same shell binary runs on machines with
// and without souveraine-pam-auth.
authProc.activeFamily = family;
authProc.command = ["sh", "-c",
"if command -v souveraine-pam-auth >/dev/null 2>&1; then " +
"souveraine-pam-auth; " +
"else " +
"pkcheck --process $$ --action-id org.souveraine.stepup --allow-user-interaction; " +
"fi"];
authProc.running = true;
stepUpPam.activeFamily = family;
if (!stepUpPam.start()) {
stepUpPam.activeFamily = "";
root._pendingCallback = null;
root._pendingFamily = "";
console.warn(`[step-up] PAM refused to start for ${family}`
+ " — is /etc/pam.d/souveraine-stepup installed?");
if (callback) callback(false);
return { ok: false, reason: "pam unavailable" };
}
console.log(`[step-up] auth started for ${family}`);
return { ok: true };
}
// respond answer the current prompt. Empty text is a legitimate answer,
// not a cancel: that is how the fingerprint factor is accepted.
function respond(text) {
if (!stepUpPam.active) return { ok: false, reason: "no auth in progress" };
stepUpPam.respond(String(text ?? ""));
return { ok: true };
}
// cancel abandon the in-flight conversation. Completion still fires, so
// the pending callback is answered rather than dropped.
function cancel() {
if (!stepUpPam.active) return { ok: false, reason: "no auth in progress" };
stepUpPam.abort();
return { ok: true };
}
// isGranted check if a valid (non-expired) grant exists for the given
// action family. Returns true only if the grant exists and has not yet
// exceeded its TTL. Does NOT trigger re-auth; it is a pure read.
@ -238,7 +258,7 @@ Singleton {
return {
grantTtlMs: root.grantTtlMs,
activeGrants: active,
authInProgress: authProc.running,
authInProgress: stepUpPam.active,
pendingFamily: root._pendingFamily,
breakGlassActive: root._breakGlassGrant !== null
};