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:
1 parent
d84e0c4d82
commit
761a7d76e9
4 files changed
+107
-29
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user