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:
parent
ec87e6a9dc
commit
6edebaeca5
2 changed files with 63 additions and 258 deletions
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue