#!/usr/bin/env python3 """User accounts, through accountsservice -- the same daemon GNOME's Users panel drives. Everything here that changes something is authorized by polkit, which prompts through the agent this desktop already runs. This script never asks for a password itself and never holds an administrator credential. The one credential it does handle is a NEW password being set. That is read from stdin, hashed by openssl reading its own stdin, and handed to accountsservice over D-Bus from inside this process. It is never an argument: argv is world-readable through /proc, so a password -- or even its hash -- passed that way is published to every process on the machine. panama-accounts snapshot panama-accounts set-real-name USER NAME panama-accounts set-icon USER PATH panama-accounts set-account-type USER standard|administrator panama-accounts set-automatic-login USER true|false panama-accounts set-password USER (new password on stdin) panama-accounts create-user USERNAME REALNAME standard|administrator panama-accounts delete-user USERNAME [keep-files|remove-files] """ from __future__ import annotations import json import os import re import subprocess import sys ACCOUNTS = "org.freedesktop.Accounts" ACCOUNTS_PATH = "/org/freedesktop/Accounts" USER_INTERFACE = "org.freedesktop.Accounts.User" # Account types as accountsservice numbers them. STANDARD, ADMINISTRATOR = 0, 1 USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") class BoundaryError(RuntimeError): """A user-visible validation or permission failure.""" def bus(): try: import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None) except Exception as error: # noqa: BLE001 - no bus is a legitimate state raise BoundaryError("The account service is not answering.") from error def call(path: str, interface: str, method: str, parameters=None, reply=None): Gio, GLib, connection = bus() try: result = connection.call_sync( ACCOUNTS, path, interface, method, parameters, GLib.VariantType(reply) if reply else None, Gio.DBusCallFlags.NONE, 120000, None) except Exception as error: # noqa: BLE001 message = str(error) # Polkit's own refusal is the common case and deserves plain words # rather than a D-Bus error string. if "not authorized" in message.lower() or "dismissed" in message.lower(): raise BoundaryError("That change was not authorized.") from error raise BoundaryError(_clean(message)) from error return result.unpack() if result is not None else None def _clean(message: str) -> str: """The last useful sentence of a D-Bus error, without the type prefix.""" trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip() return trimmed.splitlines()[0][:200] if trimmed else "That change failed." def properties(path: str) -> dict: Gio, GLib, connection = bus() result = connection.call_sync( ACCOUNTS, path, "org.freedesktop.DBus.Properties", "GetAll", GLib.Variant("(s)", (USER_INTERFACE,)), GLib.VariantType("(a{sv})"), Gio.DBusCallFlags.NONE, 20000, None) return result.unpack()[0] def user_path(username: str) -> str: if not USERNAME.fullmatch(username or ""): raise BoundaryError("That is not a user name.") from gi.repository import GLib return call(ACCOUNTS_PATH, ACCOUNTS, "FindUserByName", GLib.Variant("(s)", (username,)), "(o)")[0] def describe(path: str) -> dict: values = properties(path) icon = str(values.get("IconFile") or "") return { "path": path, "userName": str(values.get("UserName") or ""), "realName": str(values.get("RealName") or ""), # Reported only when it is actually there: accountsservice keeps the # path in its database whether or not a file exists, so a deleted # avatar otherwise shows as a broken image. "iconFile": icon if icon and os.path.isfile(icon) else "", "administrator": int(values.get("AccountType") or 0) == ADMINISTRATOR, "locked": bool(values.get("Locked")), "automaticLogin": bool(values.get("AutomaticLogin")), "loginTime": int(values.get("LoginTime") or 0), "shell": str(values.get("Shell") or ""), "homeDirectory": str(values.get("HomeDirectory") or ""), "systemAccount": bool(values.get("SystemAccount")), "uid": int(values.get("Uid") or 0), } def snapshot() -> dict: paths = call(ACCOUNTS_PATH, ACCOUNTS, "ListCachedUsers", None, "(ao)")[0] users = [describe(path) for path in paths] users = [user for user in users if not user["systemAccount"]] users.sort(key=lambda user: user["uid"]) me = os.environ.get("USER") or "" return { "users": users, "currentUser": me, # Removing the only administrator would leave a machine nobody can # administer, so the page needs to know rather than find out. "administratorCount": sum(1 for user in users if user["administrator"]), "error": "", } def set_real_name(username: str, name: str) -> None: from gi.repository import GLib if len(name) > 128 or "\n" in name or ":" in name: raise BoundaryError("That name cannot be used.") call(user_path(username), USER_INTERFACE, "SetRealName", GLib.Variant("(s)", (name,))) def set_icon(username: str, path: str) -> None: from gi.repository import GLib if not os.path.isfile(path): raise BoundaryError("That picture no longer exists.") call(user_path(username), USER_INTERFACE, "SetIconFile", GLib.Variant("(s)", (path,))) def set_account_type(username: str, kind: str) -> None: from gi.repository import GLib if kind not in ("standard", "administrator"): raise BoundaryError("That is not an account type.") call(user_path(username), USER_INTERFACE, "SetAccountType", GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,))) def set_automatic_login(username: str, enabled: bool) -> None: from gi.repository import GLib call(user_path(username), USER_INTERFACE, "SetAutomaticLogin", GLib.Variant("(b)", (enabled,))) def set_password(username: str) -> None: """Set a new password, read from stdin and never named on a command line.""" secret = sys.stdin.buffer.read() # A trailing newline from a pipe is not part of the password. if secret.endswith(b"\n"): secret = secret[:-1] if not secret: raise BoundaryError("No password was provided.") if len(secret) < 6: raise BoundaryError("That password is too short.") # openssl reads the password on ITS stdin too, so the cleartext never # appears in a process listing at any point in the chain. hashed = subprocess.run(["openssl", "passwd", "-6", "-stdin"], input=secret, capture_output=True, timeout=30, check=False) if hashed.returncode != 0 or not hashed.stdout.strip(): raise BoundaryError("The password could not be prepared.") from gi.repository import GLib call(user_path(username), USER_INTERFACE, "SetPassword", GLib.Variant("(ss)", (hashed.stdout.decode().strip(), ""))) def create_user(username: str, real_name: str, kind: str) -> None: from gi.repository import GLib if not USERNAME.fullmatch(username or ""): raise BoundaryError("A user name may use lowercase letters, digits, - and _.") if kind not in ("standard", "administrator"): raise BoundaryError("That is not an account type.") call(ACCOUNTS_PATH, ACCOUNTS, "CreateUser", GLib.Variant("(ssi)", (username, real_name, ADMINISTRATOR if kind == "administrator" else STANDARD)), "(o)") def delete_user(username: str, files: str) -> None: from gi.repository import GLib if files not in ("keep-files", "remove-files"): raise BoundaryError("Say whether to keep or remove the home directory.") if username == (os.environ.get("USER") or ""): raise BoundaryError("You cannot delete the account you are signed in to.") state = snapshot() target = next((user for user in state["users"] if user["userName"] == username), None) if target is None: raise BoundaryError("That account no longer exists.") if target["administrator"] and state["administratorCount"] <= 1: raise BoundaryError("That is the only administrator; the machine would have none.") call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser", GLib.Variant("(xb)", (target["uid"], files == "remove-files"))) def main(arguments: list[str]) -> int: try: if arguments == ["snapshot"]: print(json.dumps(snapshot(), separators=(",", ":"))) return 0 if len(arguments) == 3 and arguments[0] == "set-real-name": set_real_name(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-icon": set_icon(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-account-type": set_account_type(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-automatic-login": set_automatic_login(arguments[1], arguments[2] == "true") elif len(arguments) == 2 and arguments[0] == "set-password": set_password(arguments[1]) elif len(arguments) == 4 and arguments[0] == "create-user": create_user(arguments[1], arguments[2], arguments[3]) elif len(arguments) == 3 and arguments[0] == "delete-user": delete_user(arguments[1], arguments[2]) else: raise BoundaryError( "Usage: panama-accounts snapshot | set-real-name USER NAME | " "set-icon USER PATH | set-account-type USER standard|administrator | " "set-automatic-login USER true|false | set-password USER | " "create-user USERNAME REALNAME standard|administrator | " "delete-user USERNAME keep-files|remove-files") except BoundaryError as error: # Answers with the fresh state plus the message, so a page never has to # ask twice to find out what happened. try: state = snapshot() except BoundaryError: state = {"users": [], "currentUser": "", "administratorCount": 0} state["error"] = str(error) print(json.dumps(state, separators=(",", ":"))) return 0 print(json.dumps(snapshot(), separators=(",", ":"))) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))