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,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:

View File

@ -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:

View File

@ -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

View File

@ -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,

View File

@ -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"