diff --git a/src/services/sysauth.py b/src/services/sysauth.py index 4afeb3a..a8aab98 100644 --- a/src/services/sysauth.py +++ b/src/services/sysauth.py @@ -5,13 +5,20 @@ and listens for authentication requests from polkitd. Emits signals so the UI layer can show a password dialog. 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 3. polkitd calls BeginAuthentication → service emits signal 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 pwd +import socket +import subprocess from fabric.core.service import Service, Signal 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_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 = """ @@ -32,7 +43,7 @@ AGENT_INTROSPECTION = """ - + @@ -85,33 +96,35 @@ class SysauthService(Service): self._initialized = True super().__init__(**kwargs) - self._session_bus_id = None + self._agent_registration_id = None self._authority_proxy = None self._registered = False + self._registered_subject = None + self._pending = {} 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: True if registration succeeded, False otherwise. """ try: - bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) if bus is None: - logger.error("[SysauthService] Cannot get session bus") + logger.error("[SysauthService] Cannot get system bus") return False node_info = Gio.DBusNodeInfo.new_for_xml(AGENT_INTROSPECTION) 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, interface_info, self._handle_method_call, ) 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(): @@ -129,50 +142,165 @@ class SysauthService(Service): def stop(self) -> None: """Unregister and clean up D-Bus resources.""" - if self._session_bus_id is not None: + if self._agent_registration_id is not None: try: - bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) if bus is not None: - bus.unregister_object(self._session_bus_id) + bus.unregister_object(self._agent_registration_id) except GLib.Error: pass - self._session_bus_id = None + self._agent_registration_id = None if self._registered: self._unregister_from_authority() 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") - def respond(self, cookie: str, password: str) -> None: + def respond(self, cookie: str, password: str) -> bool: """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: cookie: The authentication session cookie. 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( - POLKIT_BUS_NAME, - AGENT_OBJECT_PATH, - AGENT_INTERFACE, - "Respond", - GLib.Variant("(ss)", (cookie, password)), - None, - Gio.DBusCallFlags.NONE, - -1, - None, + Returns: + True if authentication succeeded, False otherwise. + """ + pending = self._pending.get(cookie) + if pending is None: + logger.warning( + f"[SysauthService] respond() called for unknown cookie {cookie[:16]}" ) - except GLib.Error as e: - logger.error(f"[SysauthService] Failed to send response: {e}") + return False + + 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: - """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: system_bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) if system_bus is None: @@ -189,28 +317,60 @@ class SysauthService(Service): None, ) - pid = os.getpid() locale = os.environ.get("LANG", "en_US.UTF-8") - builder = GLib.VariantBuilder(GLib.VariantType("(sa{sv}so)")) - builder.add_value(GLib.Variant("s", "unix-process")) - details = GLib.VariantBuilder(GLib.VariantType("a{sv}")) - details.add_value(GLib.Variant("{sv}", ("pid", GLib.Variant("u", pid)))) - builder.add_value(details.end()) - builder.add_value(GLib.Variant("s", locale)) - builder.add_value(GLib.Variant("o", AGENT_OBJECT_PATH)) + candidates = [self._process_subject()] + session_subject = self._session_subject() + if session_subject is not None: + candidates.insert(0, session_subject) - self._authority_proxy.call_sync( - "RegisterAuthenticationAgent", - builder.end(), - Gio.DBusCallFlags.NONE, - -1, - None, - ) + for kind, details in candidates: + builder = GLib.VariantBuilder(GLib.VariantType("((sa{sv})ss)")) + builder.add_value(self._subject_variant(kind, details)) + builder.add_value(GLib.Variant("s", locale)) + builder.add_value(GLib.Variant("s", AGENT_OBJECT_PATH)) + 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 - logger.info(f"[SysauthService] Registered with Authority (PID {pid})") - return True + self._registered = True + self._registered_subject = (kind, details) + + 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: logger.error(f"[SysauthService] Registration failed: {e}") @@ -218,18 +378,15 @@ class SysauthService(Service): def _unregister_from_authority(self) -> None: """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 try: - pid = os.getpid() + kind, details = self._registered_subject - builder = GLib.VariantBuilder(GLib.VariantType("(sa{sv}o)")) - builder.add_value(GLib.Variant("s", "unix-process")) - details = GLib.VariantBuilder(GLib.VariantType("a{sv}")) - 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)) + builder = GLib.VariantBuilder(GLib.VariantType("((sa{sv})s)")) + builder.add_value(self._subject_variant(kind, details)) + builder.add_value(GLib.Variant("s", AGENT_OBJECT_PATH)) self._authority_proxy.call_sync( "UnregisterAuthenticationAgent", @@ -243,6 +400,63 @@ class SysauthService(Service): except GLib.Error as 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//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( self, connection: Gio.DBusConnection, @@ -270,7 +484,12 @@ class SysauthService(Service): parameters: GLib.Variant, invocation: Gio.DBusMethodInvocation, ) -> 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 = ( parameters.unpack() ) @@ -278,18 +497,15 @@ class SysauthService(Service): uid = -1 if identities: for kind, value in identities: - if kind == "unix-user": - if isinstance(value, dict): - uid = value.get("uid", -1) - elif isinstance(value, int): - uid = value - elif isinstance(value, tuple): - uid = value[0] if value else -1 + if kind == "unix-user" and isinstance(value, dict): + uid = value.get("uid", -1) break if uid == -1: uid = os.getuid() + self._pending[cookie] = {"uid": uid, "invocation": invocation} + processed_action = _process_action_id(action_id, message) logger.info( @@ -297,8 +513,6 @@ class SysauthService(Service): f"uid={uid} cookie={cookie[:16]}..." ) - invocation.return_value(None) - GLib.idle_add( lambda: self.emit( "begin-authentication", @@ -318,6 +532,11 @@ class SysauthService(Service): """Handle CancelAuthentication call from polkitd.""" (cookie,) = parameters.unpack() 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) GLib.idle_add(lambda: self.emit("authentication-cancelled", cookie))