Compare commits

..
5 Commits
Author SHA1 Message Date
Arena Agent e060ec96eb fix(hub): make Hub login verification loadable and diagnosable
Correction to the patch-0002 report: after a reboot the Hub icon asks for a password AND the correct free (desktop) password is rejected there, while desktop sign-in accepts it. Patch 0002/0003 explain the icon prompt (legacy hub_manual_logout cookie; fixed in 0003), but the rejection itself means verify_system_password() never gets a conclusive crypt answer in the Hub unit and its PAM fallback cannot load either: on NixOS a bare libcrypt.so.1/libpam.so.0 resolves through LD_LIBRARY_PATH or not at all (or to glibc's failure-token stub), and until now every stage failed silently into a generic 'Incorrect password'.

password-model.nix now puts pkgs.libxcrypt and pkgs.pam on sovran-hub-web's LD_LIBRARY_PATH so both the crypt fallbacks and PAM can actually load. verify_system_password() now logs, journal-visible and never containing a password or hash: the shadow hash scheme, every libcrypt source tried and whether it loaded, and PAM's service + return code when it has to decide.
2026-10-18 21:39:41 -05:00
Arena Agent 761a7d76e9 fix(hub): trusted Hub icon, truthful root hint, no Default-keyring prompt
1) Clicking the desktop Hub icon demanded the Hub password after any sign-out: api_logout set a hub_manual_logout cookie for 400 days and /auto-login honoured it. The icon is a trusted local entry point (its user is already signed in to the OS as free), so /auto-login now ignores and clears that legacy cookie and logout no longer sets it. Sign-out still destroys the session, and LAN clients -- who never get auto-login -- must still enter the password.

2) The Administrator (root) row in System Passwords showed the free/Hub hint verbatim. Root has no desktop or Hub sign-in and nothing refreshes its display copy, so it now gets a root-specific hint (sudo passwd root to set it; Hub Security Reset to make the tile show one again).

3) Brave prompted for a locked 'Default keyring' on Hub launch: the password cleanup removed configuration.nix's keyrings/default pointer and password-model.nix never re-seeded it, so gnome-keyring fell back to an encrypted keyring named Default. password-model.nix now seeds default -> login (the blank keyring) via tmpfiles, and its activation migration restores the pointer and moves any encrypted default.keyring aside (kept, never deleted).
2026-10-18 21:39:25 -05:00
Arena Agent d84e0c4d82 fix(hub): Hub login must accept the password the desktop accepts
verify_system_password() trusted the first loadable libcrypt. A library that cannot speak the shadow hash's algorithm (yescrypt, NixOS's default) answers with the '*0' sentinel, which was compared to the hash and reported as a wrong password -- even though the same password still signed in at the desktop. Following the System Passwords tile's 'sign out and back in' hint then locked the user out of the Hub, asking for a 'new' Hub password that does not exist: Hub login is the free account password, verified against /etc/shadow.

Try every loadable libcrypt, treat None/'*' replies as inconclusive rather than wrong, and when no library can decide, verify via PAM -- the same check GDM performs. Also replaces the sign-out hint (server + JS fallback) with wording that says the Hub has no separate password and not to sign out unless you know the current desktop password.

Recovery for an already locked-out user whose desktop login still works: on the machine itself, clear the localhost:8937 cookies and open http://localhost:8937/auto-login (localhost-only), or run 'sudo change-free-password' in a terminal to set a known free/Hub password.
2026-10-18 21:18:27 -05:00
Arena Agent e718404e84 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.
2026-10-18 21:04:35 -05:00
naturallaw777 07566c6a28 updated Sovran_Bitcoin flake for a how fix for NWC 2026-10-18 20:26:06 -05:00
7 changed files with 492 additions and 41 deletions

No files matched your search

+36 -16
View File
@@ -728,7 +728,10 @@ def _check_password(submitted: str) -> bool:
proved (e.g. changed in GNOME Settings or the pre-upgrade Hub).""" proved (e.g. changed in GNOME Settings or the pre-upgrade Hub)."""
if not sp.verify_system_password(submitted): if not sp.verify_system_password(submitted):
return False return False
try:
sp.record_known_password(submitted, only_if_stale=True) 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 return True
@@ -1808,12 +1811,25 @@ def _resolve_credential(cred: dict) -> dict | None:
# free/root login passwords: shown only if the display copy really is the # free/root login passwords: shown only if the display copy really is the
# current one (system_password verifies it against /etc/shadow). # current one (system_password verifies it against /etc/shadow).
if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE): if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):
try:
pw = (sp.read_display_password() if filepath == sp.DISPLAY_FILE pw = (sp.read_display_password() if filepath == sp.DISPLAY_FILE
else sp.read_root_display_password()) 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: if pw is None:
hint = ("Changed outside the Hub — finish the password step in onboarding to see it here." if filepath == sp.ROOT_DISPLAY_FILE:
if sp.migration_pending() else # Root is NOT the desktop/Hub sign-in: nothing a user signs
"Changed outside the Hub — sign out and back in to show it here.") # in with refreshes this copy, so never show the free text.
hint = ("Changed outside the Hub — the Hub's saved copy no longer matches the current root password (set by the installer, 'passwd', or a terminal). Root has no desktop or Hub sign-in, so signing in will not refresh this copy. If you do not know the root password, set one in a terminal with 'sudo passwd root'; to make this tile show a root password again, run a Hub Security Reset, which sets new free + root passwords and saves copies of both.")
elif migration:
hint = "Changed outside the Hub — finish the password step in onboarding to see it here."
else:
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."
return {"label": label, "value": "", "unknown": True, "hint": hint} return {"label": label, "value": "", "unknown": True, "hint": hint}
return {"label": label, "value": prefix + pw + suffix, "multiline": multiline} return {"label": label, "value": prefix + pw + suffix, "multiline": multiline}
@@ -2652,10 +2668,13 @@ async def auto_login_redirect(request: Request):
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
if client_ip not in ("127.0.0.1", "::1"): if client_ip not in ("127.0.0.1", "::1"):
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
# An explicit logout must take precedence over the desktop launcher's # The desktop Hub icon is a trusted local entry point: whoever can click
# localhost auto-login, including after the Hub window is closed/reopened. # it is already signed in to this machine as 'free', so it must never
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1": # demand the Hub password again. A legacy hub_manual_logout cookie (set
return RedirectResponse(url="/login", status_code=303) # by older versions for 400 days after any Hub sign-out) used to send
# the icon to /login forever -- ignore it and clear it. Sign-out still
# destroys the session; clients on the LAN never get auto-login and
# must sign in with the password as before.
token = _create_session() token = _create_session()
response = RedirectResponse(url="/", status_code=303) response = RedirectResponse(url="/", status_code=303)
response.set_cookie( response.set_cookie(
@@ -2666,6 +2685,7 @@ async def auto_login_redirect(request: Request):
samesite="lax", samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port secure=False, # LAN-only appliance; no TLS on the Hub port
) )
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
return response return response
@@ -2713,20 +2733,20 @@ async def api_login(req: LoginRequest, request: Request):
@app.post("/api/logout") @app.post("/api/logout")
async def api_logout(request: Request): async def api_logout(request: Request):
"""Destroy the session and prevent desktop auto-login until password login.""" """Destroy the session.
This is a real sign-out for LAN clients (they must enter the password
again). On this machine the desktop Hub icon remains a trusted,
passwordless entry point -- its user is already signed in to the OS --
so no persistent "manual logout" cookie is set any more: it made the
Hub icon demand a password for 400 days after a single sign-out.
"""
token = request.cookies.get(SESSION_COOKIE_NAME) token = request.cookies.get(SESSION_COOKIE_NAME)
if token: if token:
_destroy_session(token) _destroy_session(token)
response = JSONResponse({"ok": True}) response = JSONResponse({"ok": True})
response.delete_cookie(key=SESSION_COOKIE_NAME) response.delete_cookie(key=SESSION_COOKIE_NAME)
response.set_cookie( response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
key=MANUAL_LOGOUT_COOKIE_NAME,
value="1",
max_age=MANUAL_LOGOUT_MAX_AGE,
httponly=True,
samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port
)
return response return response
@@ -35,7 +35,7 @@ function _renderCredsHtml(credentials, unit) {
continue; continue;
} }
if (cred.unknown) { if (cred.unknown) {
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div><div class="creds-value-wrap"><div class="creds-value creds-unknown">' + escHtml(cred.hint || "Changed outside the Hub — sign out and back in to show it here.") + '</div></div></div>'; html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div><div class="creds-value-wrap"><div class="creds-value creds-unknown">' + 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.") + '</div></div></div>';
continue; continue;
} }
var displayValue = linkify(cred.value); var displayValue = linkify(cred.value);
+179 -9
View File
@@ -18,12 +18,16 @@ from __future__ import annotations
import ctypes import ctypes
import ctypes.util import ctypes.util
import hmac import hmac
import logging
import os import os
import secrets import secrets
import shutil
import subprocess import subprocess
import threading import threading
from typing import Callable from typing import Callable
logger = logging.getLogger(__name__)
USER = "free" USER = "free"
SECRETS_DIR = "/var/lib/secrets" SECRETS_DIR = "/var/lib/secrets"
DISPLAY_FILE = f"{SECRETS_DIR}/free-password" DISPLAY_FILE = f"{SECRETS_DIR}/free-password"
@@ -32,20 +36,154 @@ 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 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 _lock = threading.Lock() # crypt() returns a static buffer; serialise calls
_libcrypt = None _libcrypt_libs = None # list of every loadable libcrypt, in preference order
_libcrypt_sources: list[str] = [] # where those libraries were loaded from
# ── low level ────────────────────────────────────────────────────────────── # ── low level ──────────────────────────────────────────────────────────────
def _crypt(): def _crypt_libs() -> list:
global _libcrypt """Every libcrypt we can load, best first ($LIBCRYPT, then system ones).
if _libcrypt is None:
path = os.environ.get("LIBCRYPT") or ctypes.util.find_library("crypt") or "libcrypt.so.1" 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:
candidates.append(env_path)
found = ctypes.util.find_library("crypt")
if found:
candidates.append(found)
candidates += ["libcrypt.so.1", "libcrypt.so.2", "libcrypt.so"]
libs = []
seen = set()
for path in candidates:
try:
lib = ctypes.CDLL(path) lib = ctypes.CDLL(path)
lib.crypt.restype = ctypes.c_char_p lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
_libcrypt = lib except (OSError, AttributeError) as exc:
return _libcrypt _libcrypt_sources.append(f"{path} (unloadable: {exc})")
continue
if lib._handle in seen: # same library reached by two names
continue
seen.add(lib._handle)
libs.append(lib)
_libcrypt_sources.append(path)
_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:
rc = libpam.pam_authenticate(handle, 0)
if rc != 0:
# e.g. 7 = auth failure, 9/10 = bad item / user unknown.
# Logged (code only, never the password) so a Hub-only
# rejection can be told apart from a wrong password.
logger.warning(
"verify via PAM: service=%s user=%s pam_authenticate rc=%d",
service, user, rc)
return rc == 0
finally:
libpam.pam_end(handle, 0)
logger.warning("verify via PAM: no PAM service could be started for user=%s", user)
return None
except Exception as exc:
logger.warning("verify via PAM unavailable for user=%s: %s", user, exc)
return None
def shadow_hash(user: str = USER) -> str | None: def shadow_hash(user: str = USER) -> str | None:
@@ -68,9 +206,41 @@ def verify_system_password(password: str, user: str = USER) -> bool:
h = shadow_hash(user) h = shadow_hash(user)
if not h or not password: if not h or not password:
return False 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: with _lock:
out = _crypt().crypt(password.encode(), h.encode()) for lib in _crypt_libs():
return out is not None and hmac.compare_digest(out, h.encode()) 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:
pass
# No libcrypt could decide. Log *why* (hash scheme only -- never the
# password or hash) so a false "wrong password" at Hub login, while the
# same password signs in at the desktop, is diagnosable from
# `journalctl -u sovran-hub-web` instead of looking like user error.
scheme = h[1:].split("$", 1)[0] if h.startswith("$") else "legacy"
logger.warning(
"verify_system_password: libcrypt inconclusive for user=%s (scheme=%s, "
"sources=%r); falling back to PAM",
user, scheme, _libcrypt_sources,
)
pam_result = _verify_via_pam(password, user)
logger.warning("verify_system_password: PAM verdict for user=%s: %s", user, pam_result)
return bool(pam_result)
def _write_secret(path: str, value: str) -> None: def _write_secret(path: str, value: str) -> None:
Generated
+3 -3
View File
@@ -152,11 +152,11 @@
"nixpkgs": "nixpkgs_4" "nixpkgs": "nixpkgs_4"
}, },
"locked": { "locked": {
"lastModified": 1792361662, "lastModified": 1792372617,
"narHash": "sha256-EySy/HbufH9trjH0kEuZ2nVVk0+Sn5FoxJI7YtHrspo=", "narHash": "sha256-MLFWEcj6ZyFgFWubNPYK7IEI8NUtjbrhxhXQiI6JbPM=",
"owner": "naturallaw777", "owner": "naturallaw777",
"repo": "Sovran_Bitcoin", "repo": "Sovran_Bitcoin",
"rev": "5c13bf5e01beccc4a225a36cde833f00b2595718", "rev": "78e4430525069172a8d1a9c08a951f8b87a03bf3",
"type": "github" "type": "github"
}, },
"original": { "original": {
+33
View File
@@ -36,6 +36,17 @@ let
lock-after=false lock-after=false
''; '';
# Which keyring gnome-keyring treats as the default collection. Without
# this pointer the daemon falls back to a keyring literally named
# "Default" (default.keyring), which is created encrypted and prompts
# "An application wants access to the keyring \"Default keyring\"" --
# e.g. Brave on Hub launch -- even though the login keyring is blank.
# The pre-cleanup configuration.nix seeded this file; password-model.nix
# dropped it by mistake.
defaultKeyringPointer = pkgs.writeText "keyring-default" ''
login
'';
# Apply the installer's plaintext password ONCE: only while the account still has no # Apply the installer's plaintext password ONCE: only while the account still has no
# usable hash (nixos-install --no-root-password leaves both accounts locked). After that # usable hash (nixos-install --no-root-password leaves both accounts locked). After that
# the file is display-only and the Hub (system_password.py) is the only thing that # the file is display-only and the Hub (system_password.py) is the only thing that
@@ -88,6 +99,7 @@ in
"d ${home}/.local/share 0700 ${user} users -" "d ${home}/.local/share 0700 ${user} users -"
"d ${home}/.local/share/keyrings 0700 ${user} users -" "d ${home}/.local/share/keyrings 0700 ${user} users -"
"C ${home}/.local/share/keyrings/login.keyring 0600 ${user} users - ${blankLoginKeyring}" "C ${home}/.local/share/keyrings/login.keyring 0600 ${user} users - ${blankLoginKeyring}"
"C ${home}/.local/share/keyrings/default 0600 ${user} users - ${defaultKeyringPointer}"
]; ];
# One-time migration for machines that already have a keyring encrypted with the # One-time migration for machines that already have a keyring encrypted with the
@@ -100,6 +112,19 @@ in
mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)" mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)"
install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring" install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring"
fi fi
# Machines that already lost the default pointer may also have picked up
# an encrypted "Default keyring" (default.keyring) that prompts on every
# application start (Brave/Hub). Point the default at the blank login
# keyring again, and move any encrypted Default keyring aside (kept,
# never deleted) so the prompt cannot come back.
if [ -d "$d" ]; then
if [ ! -f "$d/default" ] || ! grep -qx 'login' "$d/default"; then
install -o ${user} -g users -m 0600 ${defaultKeyringPointer} "$d/default"
fi
if [ -f "$d/default.keyring" ] && ! head -c 9 "$d/default.keyring" | grep -q '^\[keyring\]'; then
mv "$d/default.keyring" "$d/default.keyring.encrypted-$(date +%Y%m%d)"
fi
fi
''; '';
# ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords" # ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords"
@@ -124,4 +149,12 @@ in
# ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ─────── # ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ───────
systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1"; systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1";
# ctypes fallback lookups (bare "libcrypt.so.1" / "libpam.so.0") resolve
# through LD_LIBRARY_PATH inside the unit. Without this they find nothing
# on NixOS (no ld.so.conf paths) or glibc's failure-token libcrypt stub,
# whose "*0"/NULL answers read as a wrong password at Hub login even
# though the same password signs in at the desktop. Put the real
# libxcrypt and PAM libraries on the Hub's library path.
systemd.services.sovran-hub-web.environment.LD_LIBRARY_PATH =
lib.makeLibraryPath [ pkgs.libxcrypt pkgs.pam ];
} }
+19 -12
View File
@@ -386,7 +386,13 @@ class TestFrontendAuthRecovery(unittest.TestCase):
class TestManualLogoutPersistence(unittest.TestCase): class TestManualLogoutPersistence(unittest.TestCase):
"""Explicit logout must take precedence over desktop auto-login.""" """Hub sign-out must NOT make the desktop Hub icon demand a password.
The icon is a trusted local entry point (its user is already signed in
to the OS), and the old hub_manual_logout cookie blocked it for 400
days after a single sign-out -- users read that as "the Hub is locked".
Sign-out still destroys the session; LAN clients never get auto-login.
"""
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -397,22 +403,24 @@ class TestManualLogoutPersistence(unittest.TestCase):
def _between(self, start, end): def _between(self, start, end):
return self.server.split(start, 1)[1].split(end, 1)[0] return self.server.split(start, 1)[1].split(end, 1)[0]
def test_auto_login_honors_manual_logout_cookie(self): def test_auto_login_ignores_manual_logout_cookie(self):
route = self._between( route = self._between(
'@app.get("/auto-login")', '@app.get("/auto-login")',
"class LoginRequest", "class LoginRequest",
) )
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route) self.assertNotIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertIn('RedirectResponse(url="/login"', route) self.assertNotIn('RedirectResponse(url="/login"', route)
# Legacy cookies are cleared so the icon keeps working.
self.assertIn("response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route)
def test_logout_sets_persistent_manual_logout_cookie(self): def test_logout_sets_no_persistent_manual_logout_cookie(self):
route = self._between( route = self._between(
'@app.post("/api/logout")', '@app.post("/api/logout")',
"def _get_sovran_version", "def _get_sovran_version",
) )
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route) self.assertNotIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route) self.assertIn("response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertIn("httponly=True", route) self.assertIn("_destroy_session(token)", route)
def test_password_login_clears_manual_logout_cookie(self): def test_password_login_clears_manual_logout_cookie(self):
route = self._between( route = self._between(
@@ -427,10 +435,9 @@ class TestManualLogoutPersistence(unittest.TestCase):
class TestHubBrowserProfilePersistence(unittest.TestCase): class TestHubBrowserProfilePersistence(unittest.TestCase):
"""The desktop launcher must keep a persistent browser profile. """The desktop launcher must keep a persistent browser profile.
The hub_manual_logout marker (and the session cookie) are stored in this The session cookie is stored in this profile. If the launcher used an
profile. If the launcher used an ephemeral /tmp profile that it deleted on ephemeral /tmp profile that it deleted on exit, the browser would lose
exit, closing and reopening the Hub window would wipe the marker and its session (and all site state) every time the Hub window closed.
/auto-login would silently log the user back in without a password.
""" """
@classmethod @classmethod
+221
View File
@@ -0,0 +1,221 @@
"""Regression tests for the System Passwords Hub tile.
Bug: after the password-model update (4a25ef2), clicking "System Passwords"
in The Hub showed only "Could not load service details."
That message is the frontend catch-all for a failed
GET /api/service-detail/root-password-setup.service. The new credential path
was the only one that called system_password.verify_system_password(), which
loaded libcrypt with no error handling: if LIBCRYPT pointed at a stale /
unloadable Nix store path (or libcrypt was otherwise unavailable), CDLL raised
OSError, _resolve_credential() raised, FastAPI returned 500, and the whole
modal failed -- instead of just marking that one password "unknown".
Verification must be fail-closed (return False / show the hint), never fatal.
"""
import importlib.util
import os
import sys
import unittest
import unittest.mock
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _load_system_password():
path = os.path.join(_ROOT, "app", "sovran_systemsos_web", "system_password.py")
spec = importlib.util.spec_from_file_location("system_password_under_test", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
# A real sha512crypt hash (of "hello", salt "saltsalt") so crypt() can succeed
# against the system libcrypt when the preferred path is stale.
_KNOWN_HASH = "$6$saltsalt$YTeBOLnmm3CoeJTzBuijUEtOEWCqrw/nQ8/AeMmON4LGp3k0ZiMjC1OdqzWFUAfDQBvYkOch9BWtQTBcZ1E/p0"
class VerifyNeverRaises(unittest.TestCase):
def setUp(self):
self.sp = _load_system_password()
self._old_env = os.environ.get("LIBCRYPT")
def tearDown(self):
if self._old_env is None:
os.environ.pop("LIBCRYPT", None)
else:
os.environ["LIBCRYPT"] = self._old_env
def test_unloadable_libcrypt_is_false_not_an_exception(self):
os.environ["LIBCRYPT"] = "/nix/store/stale-before-update/lib/libcrypt.so.1"
self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
# No working libcrypt anywhere: every load attempt fails.
with unittest.mock.patch.object(
self.sp.ctypes, "CDLL", side_effect=OSError("cannot open shared object")
):
self.assertFalse(self.sp.verify_system_password("hello"))
def test_stale_libcrypt_falls_back_and_still_verifies(self):
if not os.path.exists("/usr/lib/x86_64-linux-gnu/libcrypt.so.1") and \
not os.path.exists("/lib/x86_64-linux-gnu/libcrypt.so.1"):
# Wherever a system libcrypt exists, the fallback should find it
# via find_library / the bare soname; only assert the no-raise
# contract if this host has none to fall back to.
pass
os.environ["LIBCRYPT"] = "/nix/store/stale-before-update/lib/libcrypt.so.1"
self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
try:
result = self.sp.verify_system_password("hello")
except Exception as exc: # pragma: no cover - the regression itself
self.fail(f"verify_system_password raised {exc!r} instead of failing closed")
self.assertIn(result, (True, False))
def test_display_password_is_none_not_an_exception_when_unverifiable(self):
os.environ["LIBCRYPT"] = "/nix/store/stale-before-update/lib/libcrypt.so.1"
self.sp.shadow_hash = lambda user="free": _KNOWN_HASH
self.sp._read_secret = lambda path: "hello"
with unittest.mock.patch.object(
self.sp.ctypes, "CDLL", side_effect=OSError("cannot open shared object")
):
self.assertIsNone(self.sp.read_display_password())
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 HubUnitMustBeAbleToLoadItsVerifiers(unittest.TestCase):
"""Patch 0002's PAM fallback could only work if libpam.so.0 is findable
inside the Hub unit. On NixOS a bare soname resolves via LD_LIBRARY_PATH
or not at all, so the unit must put the real libxcrypt + PAM libraries
on it -- otherwise a correct desktop password reads as 'wrong password'
at Hub login while the desktop accepts it."""
def test_hub_unit_gets_real_libcrypt_and_pam_on_library_path(self):
nix = _read("modules", "password-model.nix")
self.assertIn("LD_LIBRARY_PATH", nix)
self.assertIn("pkgs.libxcrypt pkgs.pam", nix)
def test_no_unix_chkpwd_shortcut(self):
# unix_chkpwd refuses (and stalls) when invoked outside pam_unix;
# PAM is the only supported route to it.
src = _read("app", "sovran_systemsos_web", "system_password.py")
self.assertNotIn("_verify_via_unix_chkpwd", src)
class RootHintMustNotClaimItIsTheFreePassword(unittest.TestCase):
"""The root row in the System Passwords tile showed the free/hub hint
verbatim. Root has no desktop or Hub sign-in, so that text was false
and sent users looking for a sign-in that could refresh it."""
@classmethod
def setUpClass(cls):
cls.server = _read("app", "sovran_systemsos_web", "server.py")
def test_root_gets_its_own_hint(self):
self.assertIn("ROOT_DISPLAY_FILE", self.server)
start = self.server.index('if filepath == sp.ROOT_DISPLAY_FILE:')
block = self.server[start:start + 1400]
self.assertIn("Root has no desktop or Hub sign-in", block)
self.assertIn("sudo passwd root", block)
self.assertIn("Security Reset", block)
class DefaultKeyringMustStayTheBlankLoginKeyring(unittest.TestCase):
"""The password cleanup dropped the keyrings/default pointer, so
gnome-keyring fell back to an encrypted keyring literally named
"Default" and Brave prompted for it on every Hub launch."""
@classmethod
def setUpClass(cls):
cls.nix = _read("modules", "password-model.nix")
def test_default_pointer_is_seeded(self):
self.assertIn("keyring-default", self.nix)
self.assertIn("/.local/share/keyrings/default ", self.nix)
def test_encrypted_default_keyring_is_moved_aside_never_deleted(self):
self.assertIn("default.keyring.encrypted-", self.nix)
self.assertNotIn('rm "$d/default.keyring"', self.nix)
self.assertNotIn("rm -f \"$d/default.keyring\"", self.nix)
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)."""
@classmethod
def setUpClass(cls):
cls.server = _read("app", "sovran_systemsos_web", "server.py")
def test_resolve_credential_guards_system_password_calls(self):
start = self.server.index("if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):")
block = self.server[start:start + 3200]
self.assertIn("try:", block)
self.assertIn("except Exception", block)
self.assertIn('"unknown": True', block)
def test_login_heal_is_best_effort(self):
start = self.server.index("sp.record_known_password(submitted, only_if_stale=True)")
block = self.server[max(0, start - 200):start + 200]
self.assertIn("except Exception", block)
if __name__ == "__main__":
unittest.main()