From e7ab4c2e739914b7caee6809818bfb779a128c57 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Thu, 26 Mar 2026 13:35:26 +0100 Subject: [PATCH] Harden auth, user detection, faillock, and LD_PRELOAD handling - Replace os.getlogin() with pwd.getpwuid(os.getuid()) to prevent crashes in systemd/display-manager sessions without a controlling tty - Cache libpam and libc at module level instead of calling find_library() on every auth attempt (spawned ldconfig subprocess each time) - Disable password entry permanently after FAILLOCK_MAX_ATTEMPTS instead of just showing a warning while allowing unlimited retries - Fix LD_PRELOAD logic to append gtk4-layer-shell instead of skipping when LD_PRELOAD is already set (caused silent session lock fallback) - Ensure password entry keeps focus after errors and escape --- src/moonlock/auth.py | 40 ++++++++++++++++++++++++-------------- src/moonlock/lockscreen.py | 23 +++++++++++++++------- src/moonlock/main.py | 5 +++-- src/moonlock/users.py | 9 +++++---- tests/test_users.py | 19 +++++++++--------- 5 files changed, 59 insertions(+), 37 deletions(-) diff --git a/src/moonlock/auth.py b/src/moonlock/auth.py index 705a4e5..fc92d46 100644 --- a/src/moonlock/auth.py +++ b/src/moonlock/auth.py @@ -50,25 +50,35 @@ class PamConv(Structure): ] +_cached_libpam: ctypes.CDLL | None = None +_cached_libc: ctypes.CDLL | None = None + + def _get_libpam() -> ctypes.CDLL: - """Load and return the libpam shared library.""" - pam_path = ctypes.util.find_library("pam") - if not pam_path: - raise OSError("libpam not found") - return ctypes.CDLL(pam_path) + """Load and return the libpam shared library (cached after first call).""" + global _cached_libpam + if _cached_libpam is None: + pam_path = ctypes.util.find_library("pam") + if not pam_path: + raise OSError("libpam not found") + _cached_libpam = ctypes.CDLL(pam_path) + return _cached_libpam def _get_libc() -> ctypes.CDLL: - """Load and return the libc shared library.""" - libc_path = ctypes.util.find_library("c") - if not libc_path: - raise OSError("libc not found") - libc = ctypes.CDLL(libc_path) - libc.calloc.restype = c_void_p - libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t] - libc.strdup.restype = c_void_p - libc.strdup.argtypes = [c_char_p] - return libc + """Load and return the libc shared library (cached after first call).""" + global _cached_libc + if _cached_libc is None: + libc_path = ctypes.util.find_library("c") + if not libc_path: + raise OSError("libc not found") + libc = ctypes.CDLL(libc_path) + libc.calloc.restype = c_void_p + libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t] + libc.strdup.restype = c_void_p + libc.strdup.argtypes = [c_char_p] + _cached_libc = libc + return _cached_libc def _make_conv_func(password: str) -> PamConvFunc: diff --git a/src/moonlock/lockscreen.py b/src/moonlock/lockscreen.py index e230a0a..6c88fa7 100644 --- a/src/moonlock/lockscreen.py +++ b/src/moonlock/lockscreen.py @@ -42,6 +42,7 @@ class LockscreenWindow(Gtk.ApplicationWindow): self._build_ui() self._setup_keyboard() + self._password_entry.grab_focus() # Start fingerprint listener if available if self._fp_available: @@ -157,6 +158,7 @@ class LockscreenWindow(Gtk.ApplicationWindow): if keyval == Gdk.KEY_Escape: self._password_entry.set_text("") self._error_label.set_visible(False) + self._password_entry.grab_focus() return True return False @@ -173,20 +175,27 @@ class LockscreenWindow(Gtk.ApplicationWindow): return authenticate(self._user.username, password) def _on_auth_done(result: bool) -> None: - entry.set_sensitive(True) if result: self._unlock() + return + + self._failed_attempts += 1 + entry.set_text("") + + if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS: + # Permanently disable entry after max failed attempts + self._show_error(self._strings.faillock_locked) + entry.set_sensitive(False) else: - self._failed_attempts += 1 - self._show_error(self._strings.wrong_password) - if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS: - self._show_error(self._strings.faillock_locked) - elif self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1: + entry.set_sensitive(True) + entry.grab_focus() + if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1: remaining = FAILLOCK_MAX_ATTEMPTS - self._failed_attempts self._show_error( self._strings.faillock_attempts_remaining.format(n=remaining) ) - entry.set_text("") + else: + self._show_error(self._strings.wrong_password) # Use GLib thread pool to avoid blocking GTK mainloop def _auth_thread() -> bool: diff --git a/src/moonlock/main.py b/src/moonlock/main.py index cfe5e49..575627b 100644 --- a/src/moonlock/main.py +++ b/src/moonlock/main.py @@ -7,8 +7,9 @@ from importlib.resources import files # gtk4-layer-shell must be loaded before libwayland-client _LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so" -if "LD_PRELOAD" not in os.environ and os.path.exists(_LAYER_SHELL_LIB): - os.environ["LD_PRELOAD"] = _LAYER_SHELL_LIB +_existing_preload = os.environ.get("LD_PRELOAD", "") +if _LAYER_SHELL_LIB not in _existing_preload and os.path.exists(_LAYER_SHELL_LIB): + os.environ["LD_PRELOAD"] = f"{_existing_preload}:{_LAYER_SHELL_LIB}".lstrip(":") os.execvp(sys.executable, [sys.executable, "-m", "moonlock.main"] + sys.argv[1:]) import gi diff --git a/src/moonlock/users.py b/src/moonlock/users.py index 1d46a36..eba7d5c 100644 --- a/src/moonlock/users.py +++ b/src/moonlock/users.py @@ -22,14 +22,15 @@ class User: def get_current_user() -> User: """Get the currently logged-in user's info from the system.""" - username = os.getlogin() - pw = pwd.getpwnam(username) + # Use getuid() instead of getlogin() — getlogin() fails without a controlling + # terminal (systemd units, display-manager-started sessions). + pw = pwd.getpwuid(os.getuid()) gecos = pw.pw_gecos # GECOS field may contain comma-separated values; first field is the full name - display_name = gecos.split(",")[0] if gecos else username + display_name = gecos.split(",")[0] if gecos else pw.pw_name if not display_name: - display_name = username + display_name = pw.pw_name return User( username=pw.pw_name, diff --git a/tests/test_users.py b/tests/test_users.py index 3d86f0b..1808718 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -11,9 +11,9 @@ from moonlock.users import get_current_user, get_avatar_path, get_default_avatar class TestGetCurrentUser: """Tests for current user detection.""" - @patch("moonlock.users.os.getlogin", return_value="testuser") - @patch("moonlock.users.pwd.getpwnam") - def test_returns_user_with_correct_username(self, mock_pwd, mock_login): + @patch("moonlock.users.os.getuid", return_value=1000) + @patch("moonlock.users.pwd.getpwuid") + def test_returns_user_with_correct_username(self, mock_pwd, mock_uid): mock_pwd.return_value.pw_name = "testuser" mock_pwd.return_value.pw_gecos = "Test User" mock_pwd.return_value.pw_dir = "/home/testuser" @@ -22,10 +22,11 @@ class TestGetCurrentUser: assert user.username == "testuser" assert user.display_name == "Test User" assert user.home == Path("/home/testuser") + mock_pwd.assert_called_once_with(1000) - @patch("moonlock.users.os.getlogin", return_value="testuser") - @patch("moonlock.users.pwd.getpwnam") - def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_login): + @patch("moonlock.users.os.getuid", return_value=1000) + @patch("moonlock.users.pwd.getpwuid") + def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_uid): mock_pwd.return_value.pw_name = "testuser" mock_pwd.return_value.pw_gecos = "" mock_pwd.return_value.pw_dir = "/home/testuser" @@ -33,9 +34,9 @@ class TestGetCurrentUser: user = get_current_user() assert user.display_name == "testuser" - @patch("moonlock.users.os.getlogin", return_value="testuser") - @patch("moonlock.users.pwd.getpwnam") - def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_login): + @patch("moonlock.users.os.getuid", return_value=1000) + @patch("moonlock.users.pwd.getpwuid") + def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_uid): mock_pwd.return_value.pw_name = "testuser" mock_pwd.return_value.pw_gecos = "Test User,,,Room 42" mock_pwd.return_value.pw_dir = "/home/testuser"