fix(sysauth): hold BeginAuthentication reply until auth resolves

polkitd ends the authentication session the moment the BeginAuthentication
D-Bus reply returns, so replying immediately aborted every attempt with
'No session for cookie'. The invocation is now held until respond() or
cancel() resolves it, and cancelled sessions return
org.freedesktop.PolicyKit1.Error.Cancelled.

Also export the agent on the system bus (polkitd addresses agents by the
system-bus unique name used for registration) and authenticate through
polkit-agent-helper-1 via socket activation or setuid fallback so PAM
runs as root, instead of the old direct Respond call that only works
for uid 0.
This commit is contained in:
sankalp committed 2026-08-02 12:10:03 +05:45
1 parent ba4d73d58f
commit 1059833576
1 file changed
+287 -68
+287 -68
View File
@@ -5,13 +5,20 @@ and listens for authentication requests from polkitd. Emits signals
so the UI layer can show a password dialog. so the UI layer can show a password dialog.
D-Bus flow: D-Bus flow:
1. Export org.freedesktop.PolicyKit1.AuthenticationAgent on session bus 1. Export org.freedesktop.PolicyKit1.AuthenticationAgent on system bus
2. Register with Polkit Authority on system bus 2. Register with Polkit Authority on system bus
3. polkitd calls BeginAuthentication → service emits signal 3. polkitd calls BeginAuthentication → service emits signal
4. UI responds via respond(cookie, password) 4. UI responds via respond(cookie, password)
polkitd addresses the agent by the system-bus unique name it called
RegisterAuthenticationAgent with, so the agent object must be exported
on the system bus (not the session bus).
""" """
import os import os
import pwd
import socket
import subprocess
from fabric.core.service import Service, Signal from fabric.core.service import Service, Signal
from fabric.utils import Gio, GLib, logger from fabric.utils import Gio, GLib, logger
@@ -23,6 +30,10 @@ POLKIT_AUTHORITY_IFACE = "org.freedesktop.PolicyKit1.Authority"
AGENT_OBJECT_PATH = "/org/freedesktop/PolicyKit1/AuthenticationAgent" AGENT_OBJECT_PATH = "/org/freedesktop/PolicyKit1/AuthenticationAgent"
AGENT_INTERFACE = "org.freedesktop.PolicyKit1.AuthenticationAgent" AGENT_INTERFACE = "org.freedesktop.PolicyKit1.AuthenticationAgent"
AGENT_HELPER_SOCKET = "/run/polkit/agent-helper.socket"
AGENT_HELPER_PATH = "/usr/lib/polkit-1/polkit-agent-helper-1"
HELPER_TIMEOUT = 15
AGENT_INTROSPECTION = """<!DOCTYPE node> AGENT_INTROSPECTION = """<!DOCTYPE node>
<node> <node>
<interface name="org.freedesktop.PolicyKit1.AuthenticationAgent"> <interface name="org.freedesktop.PolicyKit1.AuthenticationAgent">
@@ -32,7 +43,7 @@ AGENT_INTROSPECTION = """<!DOCTYPE node>
<arg name="icon_name" type="s" direction="in"/> <arg name="icon_name" type="s" direction="in"/>
<arg name="details" type="a{ss}" direction="in"/> <arg name="details" type="a{ss}" direction="in"/>
<arg name="cookie" type="s" direction="in"/> <arg name="cookie" type="s" direction="in"/>
<arg name="identities" type="a(si)" direction="in"/> <arg name="identities" type="a(sa{sv})" direction="in"/>
</method> </method>
<method name="CancelAuthentication"> <method name="CancelAuthentication">
<arg name="cookie" type="s" direction="in"/> <arg name="cookie" type="s" direction="in"/>
@@ -85,33 +96,35 @@ class SysauthService(Service):
self._initialized = True self._initialized = True
super().__init__(**kwargs) super().__init__(**kwargs)
self._session_bus_id = None self._agent_registration_id = None
self._authority_proxy = None self._authority_proxy = None
self._registered = False self._registered = False
self._registered_subject = None
self._pending = {}
def start(self) -> bool: def start(self) -> bool:
"""Start the agent: export on session bus and register with Authority. """Start the agent: export on system bus and register with Authority.
Returns: Returns:
True if registration succeeded, False otherwise. True if registration succeeded, False otherwise.
""" """
try: try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
if bus is None: if bus is None:
logger.error("[SysauthService] Cannot get session bus") logger.error("[SysauthService] Cannot get system bus")
return False return False
node_info = Gio.DBusNodeInfo.new_for_xml(AGENT_INTROSPECTION) node_info = Gio.DBusNodeInfo.new_for_xml(AGENT_INTROSPECTION)
interface_info = node_info.lookup_interface(AGENT_INTERFACE) interface_info = node_info.lookup_interface(AGENT_INTERFACE)
self._session_bus_id = bus.register_object_with_closures2( self._agent_registration_id = bus.register_object_with_closures2(
AGENT_OBJECT_PATH, AGENT_OBJECT_PATH,
interface_info, interface_info,
self._handle_method_call, self._handle_method_call,
) )
logger.info( logger.info(
f"[SysauthService] Exported agent on session bus at {AGENT_OBJECT_PATH}" f"[SysauthService] Exported agent on system bus at {AGENT_OBJECT_PATH}"
) )
if not self._register_with_authority(): if not self._register_with_authority():
@@ -129,50 +142,165 @@ class SysauthService(Service):
def stop(self) -> None: def stop(self) -> None:
"""Unregister and clean up D-Bus resources.""" """Unregister and clean up D-Bus resources."""
if self._session_bus_id is not None: if self._agent_registration_id is not None:
try: try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
if bus is not None: if bus is not None:
bus.unregister_object(self._session_bus_id) bus.unregister_object(self._agent_registration_id)
except GLib.Error: except GLib.Error:
pass pass
self._session_bus_id = None self._agent_registration_id = None
if self._registered: if self._registered:
self._unregister_from_authority() self._unregister_from_authority()
self._registered = False self._registered = False
for pending in self._pending.values():
invocation = pending["invocation"]
GLib.idle_add(lambda: invocation.return_value(None))
self._pending.clear()
logger.info("[SysauthService] Stopped") logger.info("[SysauthService] Stopped")
def respond(self, cookie: str, password: str) -> None: def respond(self, cookie: str, password: str) -> bool:
"""Send authentication response back to polkitd. """Send authentication response back to polkitd.
Delegates to the polkit-agent-helper-1 helper (preferring the
systemd socket-activation path), which performs the PAM
authentication and invokes AuthenticationAgentResponse2/3 on the
Authority as root. Direct D-Bus response calls only accept uid 0.
On success the held BeginAuthentication reply is returned, which
is what completes the pending session in polkitd. On failure the
reply stays pending so the UI can re-prompt with the same cookie.
Args: Args:
cookie: The authentication session cookie. cookie: The authentication session cookie.
password: The password to send. password: The password to send.
"""
try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
if bus is None:
logger.error("[SysauthService] Cannot get session bus for response")
return
bus.call_sync( Returns:
POLKIT_BUS_NAME, True if authentication succeeded, False otherwise.
AGENT_OBJECT_PATH, """
AGENT_INTERFACE, pending = self._pending.get(cookie)
"Respond", if pending is None:
GLib.Variant("(ss)", (cookie, password)), logger.warning(
None, f"[SysauthService] respond() called for unknown cookie {cookie[:16]}"
Gio.DBusCallFlags.NONE,
-1,
None,
) )
except GLib.Error as e: return False
logger.error(f"[SysauthService] Failed to send response: {e}")
uid = pending["uid"]
invocation = pending["invocation"]
try:
username = pwd.getpwuid(uid).pw_name
except (KeyError, OverflowError):
logger.error(f"[SysauthService] No user found for uid {uid}")
return False
success = self._run_helper(cookie, password, username)
if success:
self._pending.pop(cookie, None)
GLib.idle_add(lambda: invocation.return_value(None))
GLib.idle_add(lambda: self.emit("authentication-completed", cookie, success))
return success
def cancel(self, cookie: str) -> None:
"""Cancel a pending authentication request from the UI side.
Returns the held BeginAuthentication reply with a cancelled
error so polkitd marks the session as dismissed.
"""
pending = self._pending.pop(cookie, None)
if pending is None:
return
invocation = pending["invocation"]
GLib.idle_add(
lambda: invocation.return_error_literal(
"org.freedesktop.PolicyKit1.Error.Cancelled",
"Authentication cancelled by user",
)
)
GLib.idle_add(lambda: self.emit("authentication-cancelled", cookie))
def _run_helper(self, cookie: str, password: str, username: str) -> bool:
"""Run polkit-agent-helper-1 via socket activation or setuid binary."""
if os.path.exists(AGENT_HELPER_SOCKET):
try:
return self._run_helper_via_socket(cookie, password, username)
except (TimeoutError, OSError) as e:
logger.warning(
f"[SysauthService] Socket helper failed, falling back to "
f"setuid helper: {e}"
)
if not os.path.exists(AGENT_HELPER_PATH):
logger.error(
f"[SysauthService] No authentication helper available at "
f"{AGENT_HELPER_PATH}"
)
return False
return self._run_helper_setuid(cookie, password, username)
def _run_helper_via_socket(self, cookie: str, password: str, username: str) -> bool:
"""Authenticate through the polkit-agent-helper systemd socket.
The helper reads the username and cookie from the first two input
lines, then the password when PAM prompts for it. Answering each
prompt keeps the conversation flowing.
"""
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(HELPER_TIMEOUT)
with sock, sock.makefile("rwb") as stream:
sock.connect(AGENT_HELPER_SOCKET)
stream.write(username.encode() + b"\n")
stream.write(cookie.encode() + b"\n")
stream.write(password.encode() + b"\n")
stream.flush()
answered = False
for raw in stream:
line = raw.decode(errors="replace").strip()
if line.startswith("PAM_PROMPT_"):
stream.write((password if not answered else "").encode() + b"\n")
stream.flush()
answered = True
elif line == "SUCCESS":
return True
elif line == "FAILURE":
return False
return False
def _run_helper_setuid(self, cookie: str, password: str, username: str) -> bool:
"""Authenticate through the setuid polkit-agent-helper-1 binary."""
try:
proc = subprocess.run(
[AGENT_HELPER_PATH, username, cookie],
input=password + "\n",
capture_output=True,
text=True,
timeout=HELPER_TIMEOUT,
check=False,
)
except subprocess.TimeoutExpired:
logger.error("[SysauthService] Setuid helper timed out")
return False
output = (proc.stdout or "").strip()
return output.endswith("SUCCESS")
def _register_with_authority(self) -> bool: def _register_with_authority(self) -> bool:
"""Register this agent with the Polkit Authority on the system bus.""" """Register this agent with the Polkit Authority on the system bus.
polkit's Authority exposes the eggdbus-style signature
``RegisterAuthenticationAgent((sa{sv}) subject, s locale,
s object_path)``. A session subject (matching how desktop agents
register) is preferred so every request in the session routes to
this agent; the process subject is the fallback.
"""
try: try:
system_bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) system_bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
if system_bus is None: if system_bus is None:
@@ -189,28 +317,60 @@ class SysauthService(Service):
None, None,
) )
pid = os.getpid()
locale = os.environ.get("LANG", "en_US.UTF-8") locale = os.environ.get("LANG", "en_US.UTF-8")
builder = GLib.VariantBuilder(GLib.VariantType("(sa{sv}so)")) candidates = [self._process_subject()]
builder.add_value(GLib.Variant("s", "unix-process")) session_subject = self._session_subject()
details = GLib.VariantBuilder(GLib.VariantType("a{sv}")) if session_subject is not None:
details.add_value(GLib.Variant("{sv}", ("pid", GLib.Variant("u", pid)))) candidates.insert(0, session_subject)
builder.add_value(details.end())
builder.add_value(GLib.Variant("s", locale))
builder.add_value(GLib.Variant("o", AGENT_OBJECT_PATH))
self._authority_proxy.call_sync( for kind, details in candidates:
"RegisterAuthenticationAgent", builder = GLib.VariantBuilder(GLib.VariantType("((sa{sv})ss)"))
builder.end(), builder.add_value(self._subject_variant(kind, details))
Gio.DBusCallFlags.NONE, builder.add_value(GLib.Variant("s", locale))
-1, builder.add_value(GLib.Variant("s", AGENT_OBJECT_PATH))
None, try:
) self._authority_proxy.call_sync(
"RegisterAuthenticationAgent",
builder.end(),
Gio.DBusCallFlags.NONE,
-1,
None,
)
except GLib.Error as e:
error_msg = str(e)
if kind == "unix-session" and "already exists" in error_msg:
logger.warning(
"[SysauthService] Session agent already registered "
"(another agent owns this session). Falling back to "
"process-scope registration: only requests originating "
"from this process will route here."
)
else:
logger.warning(
f"[SysauthService] Register with {kind} subject failed: {e}"
)
continue
self._registered = True self._registered = True
logger.info(f"[SysauthService] Registered with Authority (PID {pid})") self._registered_subject = (kind, details)
return True
if kind == "unix-session":
logger.info(
"[SysauthService] Registered with Authority "
f"(session subject, PID {os.getpid()})"
)
else:
logger.warning(
"[SysauthService] Registered with Authority using "
f"process-scope subject (PID {os.getpid()}). "
"Only requests from this process will be handled. "
"To integrate with the full session, stop the "
"existing agent (e.g. polkit-gnome-authentication-agent-1)."
)
return True
return False
except GLib.Error as e: except GLib.Error as e:
logger.error(f"[SysauthService] Registration failed: {e}") logger.error(f"[SysauthService] Registration failed: {e}")
@@ -218,18 +378,15 @@ class SysauthService(Service):
def _unregister_from_authority(self) -> None: def _unregister_from_authority(self) -> None:
"""Unregister this agent from the Polkit Authority.""" """Unregister this agent from the Polkit Authority."""
if self._authority_proxy is None: if self._authority_proxy is None or self._registered_subject is None:
return return
try: try:
pid = os.getpid() kind, details = self._registered_subject
builder = GLib.VariantBuilder(GLib.VariantType("(sa{sv}o)")) builder = GLib.VariantBuilder(GLib.VariantType("((sa{sv})s)"))
builder.add_value(GLib.Variant("s", "unix-process")) builder.add_value(self._subject_variant(kind, details))
details = GLib.VariantBuilder(GLib.VariantType("a{sv}")) builder.add_value(GLib.Variant("s", AGENT_OBJECT_PATH))
details.add_value(GLib.Variant("{sv}", ("pid", GLib.Variant("u", pid))))
builder.add_value(details.end())
builder.add_value(GLib.Variant("o", AGENT_OBJECT_PATH))
self._authority_proxy.call_sync( self._authority_proxy.call_sync(
"UnregisterAuthenticationAgent", "UnregisterAuthenticationAgent",
@@ -243,6 +400,63 @@ class SysauthService(Service):
except GLib.Error as e: except GLib.Error as e:
logger.warning(f"[SysauthService] Unregister failed: {e}") logger.warning(f"[SysauthService] Unregister failed: {e}")
def _session_subject(self) -> "tuple[str, dict] | None":
"""Build a unix-session subject from the current session, if known."""
session_id = os.environ.get("XDG_SESSION_ID")
if not session_id:
return None
return ("unix-session", {"session-id": session_id})
def _process_subject(self) -> "tuple[str, dict]":
"""Build a unix-process subject for this process.
polkit requires the ``start-time`` key (field 22 of
``/proc/<pid>/stat``, in clock ticks) alongside ``pid`` to
validate the subject.
"""
details: dict = {"pid": os.getpid()}
start_time = self._process_start_time(os.getpid())
if start_time is not None:
details["start-time"] = start_time
return ("unix-process", details)
@staticmethod
def _process_start_time(pid: int) -> "int | None":
"""Read the process start time from /proc (field 22, clock ticks)."""
try:
with open(f"/proc/{pid}/stat") as f:
data = f.read()
except OSError:
return None
name_end = data.rfind(")")
if name_end < 0:
return None
fields = data[name_end + 2 :].split()
if len(fields) < 20:
return None
try:
return int(fields[19])
except (ValueError, IndexError):
return None
def _subject_variant(self, kind: str, details: dict) -> GLib.Variant:
"""Build the ``(sa{sv})`` subject struct for the Authority."""
subject_builder = GLib.VariantBuilder(GLib.VariantType("(sa{sv})"))
subject_builder.add_value(GLib.Variant("s", kind))
details_builder = GLib.VariantBuilder(GLib.VariantType("a{sv}"))
for key, value in details.items():
if key == "pid":
variant = GLib.Variant("u", value)
elif key == "start-time":
variant = GLib.Variant("t", value)
else:
variant = GLib.Variant("s", str(value))
details_builder.add_value(GLib.Variant("{sv}", (key, variant)))
subject_builder.add_value(details_builder.end())
return subject_builder.end()
def _handle_method_call( def _handle_method_call(
self, self,
connection: Gio.DBusConnection, connection: Gio.DBusConnection,
@@ -270,7 +484,12 @@ class SysauthService(Service):
parameters: GLib.Variant, parameters: GLib.Variant,
invocation: Gio.DBusMethodInvocation, invocation: Gio.DBusMethodInvocation,
) -> None: ) -> None:
"""Handle BeginAuthentication call from polkitd.""" """Handle BeginAuthentication call from polkitd.
The D-Bus reply is deliberately held back: polkitd completes the
authentication session only when this method returns, so the
reply is sent once authentication resolves (see respond()/cancel()).
"""
action_id, message, icon_name, _details, cookie, identities = ( action_id, message, icon_name, _details, cookie, identities = (
parameters.unpack() parameters.unpack()
) )
@@ -278,18 +497,15 @@ class SysauthService(Service):
uid = -1 uid = -1
if identities: if identities:
for kind, value in identities: for kind, value in identities:
if kind == "unix-user": if kind == "unix-user" and isinstance(value, dict):
if isinstance(value, dict): uid = value.get("uid", -1)
uid = value.get("uid", -1)
elif isinstance(value, int):
uid = value
elif isinstance(value, tuple):
uid = value[0] if value else -1
break break
if uid == -1: if uid == -1:
uid = os.getuid() uid = os.getuid()
self._pending[cookie] = {"uid": uid, "invocation": invocation}
processed_action = _process_action_id(action_id, message) processed_action = _process_action_id(action_id, message)
logger.info( logger.info(
@@ -297,8 +513,6 @@ class SysauthService(Service):
f"uid={uid} cookie={cookie[:16]}..." f"uid={uid} cookie={cookie[:16]}..."
) )
invocation.return_value(None)
GLib.idle_add( GLib.idle_add(
lambda: self.emit( lambda: self.emit(
"begin-authentication", "begin-authentication",
@@ -318,6 +532,11 @@ class SysauthService(Service):
"""Handle CancelAuthentication call from polkitd.""" """Handle CancelAuthentication call from polkitd."""
(cookie,) = parameters.unpack() (cookie,) = parameters.unpack()
logger.info(f"[SysauthService] CancelAuthentication: cookie={cookie[:16]}") logger.info(f"[SysauthService] CancelAuthentication: cookie={cookie[:16]}")
pending = self._pending.pop(cookie, None)
if pending is not None:
GLib.idle_add(lambda: pending["invocation"].return_value(None))
invocation.return_value(None) invocation.return_value(None)
GLib.idle_add(lambda: self.emit("authentication-cancelled", cookie)) GLib.idle_add(lambda: self.emit("authentication-cancelled", cookie))