fix(hub): don't let password verification crash the System Passwords tile

The passwords update made the System Passwords tile the only tile whose credential resolution calls system_password.verify_system_password(). That loaded libcrypt with no error handling: an unloadable LIBCRYPT (e.g. a stale Nix store path) raised OSError through _resolve_credential(), so GET /api/service-detail/root-password-setup.service returned 500 and the Hub showed only 'Could not load service details.'

Verification now fails closed, never fatal: _crypt() falls back through LIBCRYPT / find_library / libcrypt.so.1/.so.2, verify_system_password() returns False on any load or crypt failure, _resolve_credential() degrades that one credential to the 'Changed outside the Hub' hint instead of 500-ing the modal, and the login-time display-copy heal is best-effort.

Adds tests/test_system_password.py regression tests.
This commit is contained in:
Arena Agent authored and naturallaw77 committed 2026-10-18 21:04:35 -05:00
1 parent 07566c6a28
commit e718404e84
3 files changed
+164 -11

No files matched your search

+15 -4
View File
@@ -728,7 +728,10 @@ def _check_password(submitted: str) -> bool:
proved (e.g. changed in GNOME Settings or the pre-upgrade Hub)."""
if not sp.verify_system_password(submitted):
return False
sp.record_known_password(submitted, only_if_stale=True)
try:
sp.record_known_password(submitted, only_if_stale=True)
except Exception as exc: # healing the display copy is best-effort
logger.warning("Could not heal stale password display copy: %s", exc)
return True
@@ -1808,11 +1811,19 @@ def _resolve_credential(cred: dict) -> dict | None:
# free/root login passwords: shown only if the display copy really is the
# current one (system_password verifies it against /etc/shadow).
if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):
pw = (sp.read_display_password() if filepath == sp.DISPLAY_FILE
else sp.read_root_display_password())
try:
pw = (sp.read_display_password() if filepath == sp.DISPLAY_FILE
else sp.read_root_display_password())
migration = sp.migration_pending() if pw is None else None
except Exception as exc:
# Never let one credential 500 the whole service-detail modal:
# the System Passwords tile must still open and say the password
# is unavailable instead of "Could not load service details."
logger.warning("Could not resolve %s for display: %s", filepath, exc)
pw, migration = None, None
if pw is None:
hint = ("Changed outside the Hub — finish the password step in onboarding to see it here."
if sp.migration_pending() else
if migration else
"Changed outside the Hub — sign out and back in to show it here.")
return {"label": label, "value": "", "unknown": True, "hint": hint}
return {"label": label, "value": prefix + pw + suffix, "multiline": multiline}
+37 -7
View File
@@ -40,11 +40,29 @@ _libcrypt = None
def _crypt():
global _libcrypt
if _libcrypt is None:
path = os.environ.get("LIBCRYPT") or ctypes.util.find_library("crypt") or "libcrypt.so.1"
lib = ctypes.CDLL(path)
lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
_libcrypt = lib
candidates: list[str] = []
env_path = os.environ.get("LIBCRYPT")
if env_path:
candidates.append(env_path)
found = ctypes.util.find_library("crypt")
if found:
candidates.append(found)
candidates += ["libcrypt.so.1", "libcrypt.so.2", "libcrypt.so"]
last_exc: Exception | None = None
for path in dict.fromkeys(candidates):
try:
lib = ctypes.CDLL(path)
lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
except (OSError, AttributeError) as exc:
# A stale LIBCRYPT path (e.g. a Nix store path from before an
# update / GC) must not be fatal: try the next candidate.
last_exc = exc
continue
_libcrypt = lib
break
if _libcrypt is None:
raise OSError(f"could not load libcrypt (tried {candidates!r})") from last_exc
return _libcrypt
@@ -68,8 +86,20 @@ def verify_system_password(password: str, user: str = USER) -> bool:
h = shadow_hash(user)
if not h or not password:
return False
with _lock:
out = _crypt().crypt(password.encode(), h.encode())
try:
with _lock:
out = _crypt().crypt(password.encode(), h.encode())
except Exception:
# Verification is fail-closed, never fatal. libcrypt can be missing /
# unloadable (stale LIBCRYPT Nix store path, library not yet in the
# Hub's environment after an update, unsupported hash): that must
# degrade to "password not verified / not showable", not raise into
# callers. Raising here crashed GET /api/service-detail/<unit> for
# the System Passwords tile, so the Hub showed only
# "Could not load service details." for that one tile.
return False
if isinstance(out, str):
out = out.encode()
return out is not None and hmac.compare_digest(out, h.encode())