Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e060ec96eb | ||
|
|
761a7d76e9 | ||
|
|
d84e0c4d82 | ||
|
|
e718404e84 | ||
|
|
07566c6a28 |
No files matched your search
@@ -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,12 +1811,25 @@ 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
|
||||
"Changed outside the Hub — sign out and back in to show it here.")
|
||||
if filepath == sp.ROOT_DISPLAY_FILE:
|
||||
# Root is NOT the desktop/Hub sign-in: nothing a user signs
|
||||
# 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": 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"
|
||||
if client_ip not in ("127.0.0.1", "::1"):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
# An explicit logout must take precedence over the desktop launcher's
|
||||
# localhost auto-login, including after the Hub window is closed/reopened.
|
||||
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1":
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
# The desktop Hub icon is a trusted local entry point: whoever can click
|
||||
# it is already signed in to this machine as 'free', so it must never
|
||||
# demand the Hub password again. A legacy hub_manual_logout cookie (set
|
||||
# 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()
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
response.set_cookie(
|
||||
@@ -2666,6 +2685,7 @@ async def auto_login_redirect(request: Request):
|
||||
samesite="lax",
|
||||
secure=False, # LAN-only appliance; no TLS on the Hub port
|
||||
)
|
||||
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
@@ -2713,20 +2733,20 @@ async def api_login(req: LoginRequest, request: Request):
|
||||
|
||||
@app.post("/api/logout")
|
||||
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)
|
||||
if token:
|
||||
_destroy_session(token)
|
||||
response = JSONResponse({"ok": True})
|
||||
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||||
response.set_cookie(
|
||||
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
|
||||
)
|
||||
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function _renderCredsHtml(credentials, unit) {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
var displayValue = linkify(cred.value);
|
||||
|
||||
@@ -18,12 +18,16 @@ from __future__ import annotations
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER = "free"
|
||||
SECRETS_DIR = "/var/lib/secrets"
|
||||
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
|
||||
|
||||
_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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
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:
|
||||
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.crypt.restype = ctypes.c_char_p
|
||||
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
except (OSError, AttributeError) as exc:
|
||||
_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():
|
||||
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
|
||||
return _libcrypt
|
||||
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:
|
||||
@@ -68,9 +206,41 @@ 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())
|
||||
return out is not None and hmac.compare_digest(out, h.encode())
|
||||
# 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:
|
||||
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:
|
||||
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:
|
||||
|
||||
Generated
+3
-3
@@ -152,11 +152,11 @@
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792361662,
|
||||
"narHash": "sha256-EySy/HbufH9trjH0kEuZ2nVVk0+Sn5FoxJI7YtHrspo=",
|
||||
"lastModified": 1792372617,
|
||||
"narHash": "sha256-MLFWEcj6ZyFgFWubNPYK7IEI8NUtjbrhxhXQiI6JbPM=",
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"rev": "5c13bf5e01beccc4a225a36cde833f00b2595718",
|
||||
"rev": "78e4430525069172a8d1a9c08a951f8b87a03bf3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -36,6 +36,17 @@ let
|
||||
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
|
||||
# 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
|
||||
@@ -88,6 +99,7 @@ in
|
||||
"d ${home}/.local/share 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/default 0600 ${user} users - ${defaultKeyringPointer}"
|
||||
];
|
||||
|
||||
# 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)"
|
||||
install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring"
|
||||
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"
|
||||
@@ -124,4 +149,12 @@ in
|
||||
|
||||
# ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ───────
|
||||
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
@@ -386,7 +386,13 @@ class TestFrontendAuthRecovery(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
|
||||
def setUpClass(cls):
|
||||
@@ -397,22 +403,24 @@ class TestManualLogoutPersistence(unittest.TestCase):
|
||||
def _between(self, start, end):
|
||||
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(
|
||||
'@app.get("/auto-login")',
|
||||
"class LoginRequest",
|
||||
)
|
||||
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
|
||||
self.assertIn('RedirectResponse(url="/login"', route)
|
||||
self.assertNotIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", 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(
|
||||
'@app.post("/api/logout")',
|
||||
"def _get_sovran_version",
|
||||
)
|
||||
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route)
|
||||
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
|
||||
self.assertIn("httponly=True", route)
|
||||
self.assertNotIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
|
||||
self.assertIn("response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route)
|
||||
self.assertIn("_destroy_session(token)", route)
|
||||
|
||||
def test_password_login_clears_manual_logout_cookie(self):
|
||||
route = self._between(
|
||||
@@ -427,10 +435,9 @@ class TestManualLogoutPersistence(unittest.TestCase):
|
||||
class TestHubBrowserProfilePersistence(unittest.TestCase):
|
||||
"""The desktop launcher must keep a persistent browser profile.
|
||||
|
||||
The hub_manual_logout marker (and the session cookie) are stored in this
|
||||
profile. If the launcher used an ephemeral /tmp profile that it deleted on
|
||||
exit, closing and reopening the Hub window would wipe the marker and
|
||||
/auto-login would silently log the user back in without a password.
|
||||
The session cookie is stored in this profile. If the launcher used an
|
||||
ephemeral /tmp profile that it deleted on exit, the browser would lose
|
||||
its session (and all site state) every time the Hub window closed.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user