diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index ae5242e..0213e07 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1822,9 +1822,14 @@ def _resolve_credential(cred: dict) -> dict | None: 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 migration else - "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.") + 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} @@ -2663,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( @@ -2677,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 @@ -2724,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 diff --git a/modules/password-model.nix b/modules/password-model.nix index 8391e1a..b1b02cc 100644 --- a/modules/password-model.nix +++ b/modules/password-model.nix @@ -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" diff --git a/tests/test_security.py b/tests/test_security.py index 08e214c..f8eaae4 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -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 diff --git a/tests/test_system_password.py b/tests/test_system_password.py index 5473757..94f6bb5 100644 --- a/tests/test_system_password.py +++ b/tests/test_system_password.py @@ -140,6 +140,43 @@ class WrongLibraryMustNotLockTheUserOut(unittest.TestCase): 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): """Source guards (server.py is not importable without the full Hub deps, 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): 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("except Exception", block) self.assertIn('"unknown": True', block)