diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py
index 6cab66a..ae5242e 100644
--- a/app/sovran_systemsos_web/server.py
+++ b/app/sovran_systemsos_web/server.py
@@ -1824,7 +1824,7 @@ def _resolve_credential(cred: dict) -> dict | None:
if pw is None:
hint = ("Changed outside the Hub — finish the password step in onboarding to see it here."
if migration else
- "Changed outside the Hub — sign out and back in to show it here.")
+ "Changed outside the Hub — the current password is your desktop (free) sign-in password; it will show here after you next sign in to the Hub with it. Do not sign out unless you know that password — the Hub has no separate password.")
return {"label": label, "value": "", "unknown": True, "hint": hint}
return {"label": label, "value": prefix + pw + suffix, "multiline": multiline}
diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js
index d90d5f7..ea7e100 100644
--- a/app/sovran_systemsos_web/static/js/service-detail.js
+++ b/app/sovran_systemsos_web/static/js/service-detail.js
@@ -35,7 +35,7 @@ function _renderCredsHtml(credentials, unit) {
continue;
}
if (cred.unknown) {
- html += '
' + escHtml(cred.label) + '
' + escHtml(cred.hint || "Changed outside the Hub — sign out and back in to show it here.") + '
';
+ html += '' + escHtml(cred.label) + '
' + escHtml(cred.hint || "Changed outside the Hub — the current password is your desktop (free) sign-in password; it will show here after you next sign in to the Hub with it. Do not sign out unless you know that password — the Hub has no separate password.") + '
';
continue;
}
var displayValue = linkify(cred.value);
diff --git a/app/sovran_systemsos_web/system_password.py b/app/sovran_systemsos_web/system_password.py
index b40b117..db268e3 100644
--- a/app/sovran_systemsos_web/system_password.py
+++ b/app/sovran_systemsos_web/system_password.py
@@ -32,14 +32,24 @@ MIGRATION_FILE = f"{SECRETS_DIR}/free-password-migration-newpass" # same path
LIVE_ISO_MARKER = "/iso" # NixOS live media mounts the image here; never start a migration there
_lock = threading.Lock() # crypt() returns a static buffer; serialise calls
-_libcrypt = None
+_libcrypt_libs = None # list of every loadable libcrypt, in preference order
# ── low level ──────────────────────────────────────────────────────────────
-def _crypt():
- global _libcrypt
- if _libcrypt is None:
+def _crypt_libs() -> list:
+ """Every libcrypt we can load, best first ($LIBCRYPT, then system ones).
+
+ More than one library can be *loadable* while only one can actually
+ verify the system's hashes: e.g. a bare-soname libcrypt that loads fine
+ but returns the "*0" failure sentinel for yescrypt ($y$) hashes, which
+ NixOS uses by default. Verification therefore tries each library until
+ one gives a conclusive answer instead of trusting the first that loads.
+ A stale $LIBCRYPT (Nix store path from before an update / GC) is skipped,
+ never fatal.
+ """
+ global _libcrypt_libs
+ if _libcrypt_libs is None:
candidates: list[str] = []
env_path = os.environ.get("LIBCRYPT")
if env_path:
@@ -48,22 +58,115 @@ def _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):
+ libs = []
+ seen = set()
+ for path in 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
+ except (OSError, AttributeError):
continue
- _libcrypt = lib
- break
- if _libcrypt is None:
- raise OSError(f"could not load libcrypt (tried {candidates!r})") from last_exc
- return _libcrypt
+ if lib._handle in seen: # same library reached by two names
+ continue
+ seen.add(lib._handle)
+ libs.append(lib)
+ _libcrypt_libs = libs
+ return _libcrypt_libs
+
+
+def _crypt():
+ libs = _crypt_libs()
+ if not libs:
+ raise OSError("could not load any libcrypt")
+ return libs[0]
+
+
+# ── PAM fallback ───────────────────────────────────────────────────────────
+# crypt() only answers conclusively when a loaded library understands the
+# shadow hash's algorithm. When none does (sentinel replies, no library),
+# ask PAM instead -- it is literally the check GDM/sudo perform, so a
+# password the desktop accepts can never be refused by the Hub just
+# because libcrypt could not speak its hash format.
+
+_PAM_SERVICE_CANDIDATES = ("login", "system-auth", "gdm-password", "sudo")
+
+
+class _PamMessage(ctypes.Structure):
+ _fields_ = [("msg_style", ctypes.c_int), ("msg", ctypes.c_char_p)]
+
+
+class _PamResponse(ctypes.Structure):
+ _fields_ = [("resp", ctypes.c_char_p), ("resp_retcode", ctypes.c_int)]
+
+
+_PAM_CONV_FUNC = ctypes.CFUNCTYPE(
+ ctypes.c_int, ctypes.c_int,
+ ctypes.POINTER(ctypes.POINTER(_PamMessage)),
+ ctypes.POINTER(ctypes.POINTER(_PamResponse)), ctypes.c_void_p,
+)
+
+
+class _PamConv(ctypes.Structure):
+ _fields_ = [("conv", _PAM_CONV_FUNC), ("appdata_ptr", ctypes.c_void_p)]
+
+
+def _verify_via_pam(password: str, user: str) -> bool | None:
+ """True/False if PAM gave a verdict, None if PAM is unavailable here."""
+ try:
+ libpam = None
+ for candidate in dict.fromkeys(
+ filter(None, [ctypes.util.find_library("pam"), "libpam.so.0"])):
+ try:
+ libpam = ctypes.CDLL(candidate)
+ break
+ except OSError:
+ continue
+ if libpam is None:
+ return None
+ libc = ctypes.CDLL(None)
+ libc.calloc.restype = ctypes.c_void_p
+ libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t]
+ libc.strdup.restype = ctypes.c_void_p
+ libc.strdup.argtypes = [ctypes.c_char_p]
+ libpam.pam_start.restype = ctypes.c_int
+ libpam.pam_start.argtypes = [ctypes.c_char_p, ctypes.c_char_p,
+ ctypes.POINTER(_PamConv), ctypes.POINTER(ctypes.c_void_p)]
+ libpam.pam_authenticate.restype = ctypes.c_int
+ libpam.pam_authenticate.argtypes = [ctypes.c_void_p, ctypes.c_int]
+ libpam.pam_end.restype = ctypes.c_int
+ libpam.pam_end.argtypes = [ctypes.c_void_p, ctypes.c_int]
+
+ pw_bytes = password.encode()
+
+ def _conv(num_msg, msg, resp, _appdata):
+ if num_msg <= 0:
+ return 1 # PAM_CONV_ERR
+ responses = libc.calloc(num_msg, ctypes.sizeof(_PamResponse))
+ if not responses:
+ return 1
+ resp_array = ctypes.cast(responses, ctypes.POINTER(_PamResponse))
+ for i in range(num_msg):
+ style = msg[i].contents.msg_style
+ if style in (1, 2): # PROMPT_ECHO_OFF / PROMPT_ECHO_ON
+ resp_array[i].resp = ctypes.cast(libc.strdup(pw_bytes), ctypes.c_char_p)
+ resp[0] = ctypes.cast(responses, ctypes.POINTER(_PamResponse))
+ return 0 # PAM_SUCCESS
+
+ conv_fn = _PAM_CONV_FUNC(_conv) # keep alive for the pam_* calls
+ conv = _PamConv(conv=conv_fn, appdata_ptr=None)
+ for service in _PAM_SERVICE_CANDIDATES:
+ handle = ctypes.c_void_p()
+ if libpam.pam_start(service.encode(), user.encode(),
+ ctypes.byref(conv), ctypes.byref(handle)) != 0:
+ continue
+ try:
+ return libpam.pam_authenticate(handle, 0) == 0
+ finally:
+ libpam.pam_end(handle, 0)
+ return None
+ except Exception:
+ return None
def shadow_hash(user: str = USER) -> str | None:
@@ -86,21 +189,30 @@ def verify_system_password(password: str, user: str = USER) -> bool:
h = shadow_hash(user)
if not h or not password:
return False
+ # Try every loadable libcrypt. A reply of None or "*0"/"*1" is a library
+ # saying "I don't understand this hash" (e.g. no yescrypt support) --
+ # inconclusive, NOT a wrong password -- so keep trying, and finally ask
+ # PAM. A real hash that simply doesn't match is conclusive: wrong
+ # password. Nothing here may raise: raising crashed the System
+ # Passwords tile ("Could not load service details."), and returning a
+ # false "wrong password" at login would lock a user whose desktop
+ # login still works out of the Hub.
try:
with _lock:
- out = _crypt().crypt(password.encode(), h.encode())
+ for lib in _crypt_libs():
+ try:
+ out = lib.crypt(password.encode(), h.encode())
+ except Exception:
+ continue
+ if isinstance(out, str):
+ out = out.encode()
+ if out is None or out.startswith(b"*"):
+ continue
+ return hmac.compare_digest(out, 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/ 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())
+ pass
+ pam_result = _verify_via_pam(password, user)
+ return bool(pam_result)
def _write_secret(path: str, value: str) -> None:
diff --git a/tests/test_system_password.py b/tests/test_system_password.py
index 92b72bd..5473757 100644
--- a/tests/test_system_password.py
+++ b/tests/test_system_password.py
@@ -87,6 +87,59 @@ class VerifyNeverRaises(unittest.TestCase):
self.assertIsNone(self.sp.read_root_display_password())
+class WrongLibraryMustNotLockTheUserOut(unittest.TestCase):
+ """A loadable libcrypt that cannot speak the shadow hash's algorithm
+ (yescrypt $y$ on NixOS) answers with the "*0" sentinel. That used to be
+ compared against the hash, found unequal, and reported as a *wrong
+ password* at Hub login -- locking out a user whose desktop login with
+ the very same password still worked, after the tile's hint told them
+ to sign out. Inconclusive answers must fall through to the next
+ library, and finally to PAM (the check GDM itself performs)."""
+
+ def setUp(self):
+ self.sp = _load_system_password()
+ self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
+ self._old_libs = self.sp._libcrypt_libs
+
+ def tearDown(self):
+ self.sp._libcrypt_libs = self._old_libs
+
+ def test_sentinel_from_first_library_falls_through_to_the_next(self):
+ class _SentinelLib:
+ @staticmethod
+ def crypt(_pw, _salt):
+ return b"*0"
+
+ class _RealLib:
+ @staticmethod
+ def crypt(pw, salt):
+ return _KNOWN_HASH.encode() if pw == b"hello" else b"$6$saltsalt$nomatch"
+
+ self.sp._libcrypt_libs = [_SentinelLib, _RealLib]
+ self.assertTrue(self.sp.verify_system_password("hello"))
+ self.assertFalse(self.sp.verify_system_password("not-the-password"))
+
+ def test_when_no_library_understands_the_hash_pam_decides(self):
+ class _SentinelLib:
+ @staticmethod
+ def crypt(_pw, _salt):
+ return b"*0"
+
+ self.sp._libcrypt_libs = [_SentinelLib]
+ with unittest.mock.patch.object(
+ self.sp, "_verify_via_pam", return_value=True
+ ) as pam:
+ self.assertTrue(self.sp.verify_system_password("hello"))
+ pam.assert_called_once()
+
+ def test_hint_no_longer_tells_the_user_to_sign_out_blind(self):
+ for text in (
+ _read("app", "sovran_systemsos_web", "server.py"),
+ _read("app", "sovran_systemsos_web", "static", "js", "service-detail.js"),
+ ):
+ self.assertNotIn("sign out and back in to show it here", text)
+
+
class ServiceDetailCannotBeKilledByOneCredential(unittest.TestCase):
"""Source guards (server.py is not importable without the full Hub deps,
same reason the other suites read it as text)."""