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

@ -1,215 +0,0 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Souveraine <souveraine@wiuf.net>
Date: Wed, 12 Aug 2026 12:00:00 -0400
Subject: [PATCH] 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 denied. The header also described passing the password
through SOUVERAINE_STEPUP_PASSWORD, which the code never set.
It now uses PamContext against the system souveraine-stepup service --
the same mechanism LockContext already uses for fprintd.conf, and what
doctrine 3 asks for: a second, narrower context that never unlocks the
session.
The prompt is not owned here. pamMessage raises promptRequired(family,
message, secret) and a surface answers with respond(). An empty answer is
legitimate rather than a cancel: the FPC factor prompts "Touch and hold"
and consumes its ticket on a blank response.
Deliberately unchanged: the grant model, TTL, revocation and break-glass
all still live in the shell. Moving them to sessiond is a separate pass --
grants that die with a shell reload, and a break-glass override that
journals only to console.log, are both real and neither is this patch.
Verified: PamContext's API against quickshell 0.2.1 sources (message,
responseVisible, start, abort, PamResult.toString all exist);
/usr/lib/qt6/bin/qmllint exit 0.
Untested: nothing has run this. No shell has loaded it and no prompt
surface calls respond() yet, so the path is reachable only from an IPC
call or a future settings surface.
Requires /etc/pam.d/souveraine-stepup, now shipped by the souveraine
package. Without it start() returns false and the request refuses loudly.
---
--- a/surfaces/quickshell/services/StepUpAuth.qml
+++ b/surfaces/quickshell/services/StepUpAuth.qml
@@ -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 @@
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 @@
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.
- //
- // 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)
+ // --- 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.
//
- // 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 @@
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 @@
} 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 @@
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 @@
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 @@
return {
grantTtlMs: root.grantTtlMs,
activeGrants: active,
- authInProgress: authProc.running,
+ authInProgress: stepUpPam.active,
pendingFamily: root._pendingFamily,
breakGlassActive: root._breakGlassGrant !== null
};

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
};