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).
This commit is contained in:
Arena Agent authored and naturallaw77 committed 2026-10-18 21:39:25 -05:00
1 parent d84e0c4d82
commit 761a7d76e9
4 files changed
+107 -29

No files matched your search

+25 -16
View File
@@ -1822,9 +1822,14 @@ def _resolve_credential(cred: dict) -> dict | None:
logger.warning("Could not resolve %s for display: %s", filepath, exc) logger.warning("Could not resolve %s for display: %s", filepath, exc)
pw, migration = None, None 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 migration else # Root is NOT the desktop/Hub sign-in: nothing a user signs
"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.") # 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}
@@ -2663,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(
@@ -2677,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
@@ -2724,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
+25
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"
+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
+38 -1
View File
@@ -140,6 +140,43 @@ class WrongLibraryMustNotLockTheUserOut(unittest.TestCase):
self.assertNotIn("sign out and back in to show it here", text) self.assertNotIn("sign out and back in to show it here", text)
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): class ServiceDetailCannotBeKilledByOneCredential(unittest.TestCase):
"""Source guards (server.py is not importable without the full Hub deps, """Source guards (server.py is not importable without the full Hub deps,
same reason the other suites read it as text).""" same reason the other suites read it as text)."""
@@ -150,7 +187,7 @@ class ServiceDetailCannotBeKilledByOneCredential(unittest.TestCase):
def test_resolve_credential_guards_system_password_calls(self): def test_resolve_credential_guards_system_password_calls(self):
start = self.server.index("if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):") start = self.server.index("if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):")
block = self.server[start:start + 1600] block = self.server[start:start + 3200]
self.assertIn("try:", block) self.assertIn("try:", block)
self.assertIn("except Exception", block) self.assertIn("except Exception", block)
self.assertIn('"unknown": True', block) self.assertIn('"unknown": True', block)