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
This commit is contained in:
nevaforget 2026-03-26 13:35:26 +01:00
parent dd9937b020
commit e7ab4c2e73
5 changed files with 59 additions and 37 deletions

View File

@ -50,16 +50,25 @@ class PamConv(Structure):
] ]
_cached_libpam: ctypes.CDLL | None = None
_cached_libc: ctypes.CDLL | None = None
def _get_libpam() -> ctypes.CDLL: def _get_libpam() -> ctypes.CDLL:
"""Load and return the libpam shared library.""" """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") pam_path = ctypes.util.find_library("pam")
if not pam_path: if not pam_path:
raise OSError("libpam not found") raise OSError("libpam not found")
return ctypes.CDLL(pam_path) _cached_libpam = ctypes.CDLL(pam_path)
return _cached_libpam
def _get_libc() -> ctypes.CDLL: def _get_libc() -> ctypes.CDLL:
"""Load and return the libc shared library.""" """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") libc_path = ctypes.util.find_library("c")
if not libc_path: if not libc_path:
raise OSError("libc not found") raise OSError("libc not found")
@ -68,7 +77,8 @@ def _get_libc() -> ctypes.CDLL:
libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t] libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t]
libc.strdup.restype = c_void_p libc.strdup.restype = c_void_p
libc.strdup.argtypes = [c_char_p] libc.strdup.argtypes = [c_char_p]
return libc _cached_libc = libc
return _cached_libc
def _make_conv_func(password: str) -> PamConvFunc: def _make_conv_func(password: str) -> PamConvFunc:

View File

@ -42,6 +42,7 @@ class LockscreenWindow(Gtk.ApplicationWindow):
self._build_ui() self._build_ui()
self._setup_keyboard() self._setup_keyboard()
self._password_entry.grab_focus()
# Start fingerprint listener if available # Start fingerprint listener if available
if self._fp_available: if self._fp_available:
@ -157,6 +158,7 @@ class LockscreenWindow(Gtk.ApplicationWindow):
if keyval == Gdk.KEY_Escape: if keyval == Gdk.KEY_Escape:
self._password_entry.set_text("") self._password_entry.set_text("")
self._error_label.set_visible(False) self._error_label.set_visible(False)
self._password_entry.grab_focus()
return True return True
return False return False
@ -173,20 +175,27 @@ class LockscreenWindow(Gtk.ApplicationWindow):
return authenticate(self._user.username, password) return authenticate(self._user.username, password)
def _on_auth_done(result: bool) -> None: def _on_auth_done(result: bool) -> None:
entry.set_sensitive(True)
if result: if result:
self._unlock() self._unlock()
else: return
self._failed_attempts += 1 self._failed_attempts += 1
self._show_error(self._strings.wrong_password) entry.set_text("")
if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS: if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS:
# Permanently disable entry after max failed attempts
self._show_error(self._strings.faillock_locked) self._show_error(self._strings.faillock_locked)
elif self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1: entry.set_sensitive(False)
else:
entry.set_sensitive(True)
entry.grab_focus()
if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1:
remaining = FAILLOCK_MAX_ATTEMPTS - self._failed_attempts remaining = FAILLOCK_MAX_ATTEMPTS - self._failed_attempts
self._show_error( self._show_error(
self._strings.faillock_attempts_remaining.format(n=remaining) 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 # Use GLib thread pool to avoid blocking GTK mainloop
def _auth_thread() -> bool: def _auth_thread() -> bool:

View File

@ -7,8 +7,9 @@ from importlib.resources import files
# gtk4-layer-shell must be loaded before libwayland-client # gtk4-layer-shell must be loaded before libwayland-client
_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so" _LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so"
if "LD_PRELOAD" not in os.environ and os.path.exists(_LAYER_SHELL_LIB): _existing_preload = os.environ.get("LD_PRELOAD", "")
os.environ["LD_PRELOAD"] = _LAYER_SHELL_LIB 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:]) os.execvp(sys.executable, [sys.executable, "-m", "moonlock.main"] + sys.argv[1:])
import gi import gi

View File

@ -22,14 +22,15 @@ class User:
def get_current_user() -> User: def get_current_user() -> User:
"""Get the currently logged-in user's info from the system.""" """Get the currently logged-in user's info from the system."""
username = os.getlogin() # Use getuid() instead of getlogin() — getlogin() fails without a controlling
pw = pwd.getpwnam(username) # terminal (systemd units, display-manager-started sessions).
pw = pwd.getpwuid(os.getuid())
gecos = pw.pw_gecos gecos = pw.pw_gecos
# GECOS field may contain comma-separated values; first field is the full name # 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: if not display_name:
display_name = username display_name = pw.pw_name
return User( return User(
username=pw.pw_name, username=pw.pw_name,

View File

@ -11,9 +11,9 @@ from moonlock.users import get_current_user, get_avatar_path, get_default_avatar
class TestGetCurrentUser: class TestGetCurrentUser:
"""Tests for current user detection.""" """Tests for current user detection."""
@patch("moonlock.users.os.getlogin", return_value="testuser") @patch("moonlock.users.os.getuid", return_value=1000)
@patch("moonlock.users.pwd.getpwnam") @patch("moonlock.users.pwd.getpwuid")
def test_returns_user_with_correct_username(self, mock_pwd, mock_login): def test_returns_user_with_correct_username(self, mock_pwd, mock_uid):
mock_pwd.return_value.pw_name = "testuser" mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gecos = "Test User" mock_pwd.return_value.pw_gecos = "Test User"
mock_pwd.return_value.pw_dir = "/home/testuser" mock_pwd.return_value.pw_dir = "/home/testuser"
@ -22,10 +22,11 @@ class TestGetCurrentUser:
assert user.username == "testuser" assert user.username == "testuser"
assert user.display_name == "Test User" assert user.display_name == "Test User"
assert user.home == Path("/home/testuser") assert user.home == Path("/home/testuser")
mock_pwd.assert_called_once_with(1000)
@patch("moonlock.users.os.getlogin", return_value="testuser") @patch("moonlock.users.os.getuid", return_value=1000)
@patch("moonlock.users.pwd.getpwnam") @patch("moonlock.users.pwd.getpwuid")
def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_login): 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_name = "testuser"
mock_pwd.return_value.pw_gecos = "" mock_pwd.return_value.pw_gecos = ""
mock_pwd.return_value.pw_dir = "/home/testuser" mock_pwd.return_value.pw_dir = "/home/testuser"
@ -33,9 +34,9 @@ class TestGetCurrentUser:
user = get_current_user() user = get_current_user()
assert user.display_name == "testuser" assert user.display_name == "testuser"
@patch("moonlock.users.os.getlogin", return_value="testuser") @patch("moonlock.users.os.getuid", return_value=1000)
@patch("moonlock.users.pwd.getpwnam") @patch("moonlock.users.pwd.getpwuid")
def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_login): 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_name = "testuser"
mock_pwd.return_value.pw_gecos = "Test User,,,Room 42" mock_pwd.return_value.pw_gecos = "Test User,,,Room 42"
mock_pwd.return_value.pw_dir = "/home/testuser" mock_pwd.return_value.pw_dir = "/home/testuser"