Compare 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)."""
|
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);
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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