From 4a25ef24d1a8443904a46b083edb4e05e72735b4 Mon Sep 17 00:00:00 2001 From: naturallaw777 Date: Fri, 16 Oct 2026 16:50:49 +0000 Subject: [PATCH] password & keyring cleanup: one password, one truth, no prompts The Hub, GDM and sudo now share /etc/shadow as the single source of truth. /var/lib/secrets/{free,root}-password become root-only display copies, written only by system_password.py and verified against shadow before being shown. The GNOME login keyring is decoupled from the login password (blank master password, pam_gnome_keyring off), so nothing needs re-keying or wiping when the password changes and the keyring never pops an unlock prompt. - add app/sovran_systemsos_web/system_password.py: the single module that verifies / sets / displays the free + root passwords and drives the legacy-migration onboarding step (same endpoints and UI) - add modules/password-model.nix: PAM gnome-keyring off, blank login.keyring seed with a one-time migration of old encrypted keyrings, first-boot-only password application (no more boot-revert), LIBCRYPT for the Hub - server.py: login verifies against shadow and heals stale display copies; change-password takes the current password; security reset sets both passwords through the same writer (root is shown as a password again, not a hash); all keyring wipes removed - credentials.nix: drop root-password-setup, free-password-setup and free-password-migration (replaced by password-model.nix) and the keyring wipe in change-free-password - configuration.nix: drop enableGnomeKeyring and the keyring tmpfiles hack (replaced by password-model.nix) - service-detail.js: "Current password" field; credentials changed outside the Hub render with a hint instead of a stale value Installed machines keep their exact passwords: the first-boot services no-op once an account has a real hash, and the upgrade never writes /etc/shadow. The legacy-migration onboarding step and "reset all passwords" keep working. --- app/sovran_systemsos_web/server.py | 299 +++--------------- .../static/css/modals.css | 3 + .../static/js/service-detail.js | 23 +- app/sovran_systemsos_web/system_password.py | 231 ++++++++++++++ configuration.nix | 12 - modules/credentials.nix | 171 ---------- modules/modules.nix | 1 + modules/password-model.nix | 127 ++++++++ 8 files changed, 430 insertions(+), 437 deletions(-) create mode 100644 app/sovran_systemsos_web/system_password.py create mode 100644 modules/password-model.nix diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index cff1299..c0703fc 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -5,9 +5,7 @@ from __future__ import annotations import asyncio import base64 import contextlib -import glob import hashlib -import hmac import ipaddress import json import logging @@ -38,6 +36,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from .config import load_config, load_versions from . import systemctl as sysctl +from . import system_password as sp from sovran_nwc import nwc_hub_manager as _nwc_mgr from . import support_ops as _support_ops from .ddns_update import normalise_url as _normalise_ddns_url @@ -150,9 +149,7 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" # ── Hub web authentication ──────────────────────────────────────── -FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" -FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web" -MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass" +# free/root password verify/set/display lives in system_password.py HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret" SESSION_COOKIE_NAME = "hub_session" MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout" @@ -724,58 +721,20 @@ def _is_authenticated(request: Request) -> bool: return True -def _read_free_password() -> str | None: - """Return the contents of the free-password secrets file, or None.""" - try: - with open(FREE_PASSWORD_FILE, "r") as f: - return f.read().strip() - except Exception: - return None - - - - -def _hash_password(password: str) -> str: - """Return a scrypt-hash with 16-byte salt, formatted salt_hex:hash_hex.""" - salt = os.urandom(16) - hashed = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1) - return salt.hex() + ":" + hashed.hex() - - def _check_password(submitted: str) -> bool: - """Constant-time comparison against stored scrypt hash or legacy plaintext.""" - for fp in (FREE_PASSWORD_FILE_WEB, FREE_PASSWORD_FILE): - try: - with open(fp, "r") as f: - stored = f.read().strip() - except Exception: - continue - if not stored: - continue - if ":" in stored: - try: - salt_hex, hash_hex = stored.split(":", 1) - salt = bytes.fromhex(salt_hex) - submitted_hash = hashlib.scrypt(submitted.encode(), salt=salt, n=16384, r=8, p=1) - if hmac.compare_digest(submitted_hash.hex().encode(), hash_hex.encode()): - return True - except Exception: - pass - else: - if hmac.compare_digest(submitted.encode(), stored.encode()): - try: - with open(FREE_PASSWORD_FILE_WEB, "w") as f: - f.write(_hash_password(submitted)) - os.chmod(FREE_PASSWORD_FILE_WEB, 0o600) - except Exception: - pass - return True - return False + """Verify against /etc/shadow -- the same password GDM accepts. + + On success, heal a stale display copy with the password the user just + 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) + return True def _ensure_onboarding_reopened_for_migration() -> None: """Re-open onboarding when a migration password disclosure is pending.""" - if not os.path.isfile(MIGRATION_NEWPASS_FILE): + if not sp.ensure_migration_pending(_generate_diceware_password): return try: os.remove(ONBOARDING_FLAG) @@ -1846,6 +1805,18 @@ def _resolve_credential(cred: dict) -> dict | None: if not filepath: return 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()) + 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.") + return {"label": label, "value": "", "unknown": True, "hint": hint} + return {"label": label, "value": prefix + pw + suffix, "multiline": multiline} + try: with open(filepath, "r") as f: raw = f.read().strip() @@ -2863,69 +2834,19 @@ async def api_onboarding_complete(): @app.get("/api/migration/password-status") async def api_migration_password_status(): """Return whether a migration-generated password is awaiting acknowledgement.""" - try: - with open(MIGRATION_NEWPASS_FILE, "r") as f: - return {"pending": True, "password": f.read().strip()} - except FileNotFoundError: - return {"pending": False} - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not read migration password: {exc}") + pw = sp.migration_pending() + return {"pending": True, "password": pw} if pw else {"pending": False} @app.post("/api/migration/password-acknowledge") async def api_migration_password_acknowledge(): - """Acknowledge the migration password and update /etc/shadow to match.""" - # Read the new password before deleting the file - new_password = None + """Acknowledge the migration password: apply it to /etc/shadow and the display copy.""" try: - with open(MIGRATION_NEWPASS_FILE, "r") as f: - new_password = f.read().strip() - except FileNotFoundError: - pass - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not read migration password: {exc}") - - # Update /etc/shadow so GDM accepts the new password going forward - if new_password: - chpasswd_bin = ( - shutil.which("chpasswd") - or ("/run/current-system/sw/bin/chpasswd" - if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None) - ) - if chpasswd_bin: - try: - result = subprocess.run( - [chpasswd_bin], - input=f"free:{new_password}", - capture_output=True, - text=True, - ) - if result.returncode != 0: - logger.warning( - "chpasswd failed during migration acknowledge (rc=%d): %s", - result.returncode, - (result.stderr or result.stdout).strip(), - ) - except Exception as exc: - logger.warning("chpasswd exception during migration acknowledge: %s", exc) - - # Clear only the locked keyring databases, leaving the directory and 'default' pointer intact. - keyring_dir = "/home/free/.local/share/keyrings" - keyring_files = glob.glob(os.path.join(keyring_dir, "*.keyring")) - for kf in keyring_files: - try: - os.remove(kf) - except OSError as exc: - logger.warning("Could not remove old keyring file %s: %s", kf, exc) - - # Clear the pending marker - try: - os.remove(MIGRATION_NEWPASS_FILE) - except FileNotFoundError: - pass - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not clear migration password: {exc}") - + ok = await asyncio.get_event_loop().run_in_executor(None, sp.acknowledge_migration) + except subprocess.CalledProcessError as exc: + raise HTTPException(status_code=500, detail=(exc.stderr or "chpasswd failed").strip()) + if not ok: + raise HTTPException(status_code=404, detail="No migration password pending.") return {"ok": True} @@ -5558,86 +5479,16 @@ async def api_security_reset(): new_free_password = _generate_diceware_password() new_root_password = _generate_diceware_password() - # Locate chpasswd - chpasswd_bin = ( - shutil.which("chpasswd") - or ("/run/current-system/sw/bin/chpasswd" - if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None) - ) - - if chpasswd_bin: - # Set free user password + # Set the new passwords (shadow first, then the plaintext display copies the + # credentials view shows). No keyring handling: the login keyring is + # password-independent and does not need re-keying or wiping. + for setter, pw, tag in ((sp.set_free_password, new_free_password, "free"), + (sp.set_root_password, new_root_password, "root")): try: - result = subprocess.run( - [chpasswd_bin], - input=f"free:{new_free_password}", - capture_output=True, text=True, - ) - if result.returncode != 0: - logger.warning("chpasswd free failed: %s", (result.stderr or result.stdout).strip()) - errors.append("chpasswd_free_failed") + setter(pw) except Exception as exc: - logger.warning("chpasswd free error: %s", exc) - errors.append("chpasswd_free_failed") - - # Set root password - try: - result = subprocess.run( - [chpasswd_bin], - input=f"root:{new_root_password}", - capture_output=True, text=True, - ) - if result.returncode != 0: - logger.warning("chpasswd root failed: %s", (result.stderr or result.stdout).strip()) - errors.append("chpasswd_root_failed") - except Exception as exc: - logger.warning("chpasswd root error: %s", exc) - errors.append("chpasswd_root_failed") - else: - logger.warning("chpasswd not found; passwords not reset") - errors.append("chpasswd_not_found") - - # Write new passwords to secrets files - try: - os.makedirs("/var/lib/secrets", exist_ok=True) - try: - import subprocess - subprocess.run( - ["chpasswd"], - input=f"free:{new_free_password}\n".encode(), - check=True, - capture_output=True, - ) - except Exception: - pass - try: - with open(FREE_PASSWORD_FILE_WEB, "w") as f: - f.write(_hash_password(new_free_password)) - os.chmod(FREE_PASSWORD_FILE_WEB, 0o600) - except Exception: - pass - except Exception as exc: - logger.warning("write free-password error: %s", exc) - errors.append("write_free_password_failed") - - try: - os.makedirs("/var/lib/secrets", exist_ok=True) - with open("/var/lib/secrets/root-password", "w") as f: - f.write(_hash_password(new_root_password)) - os.chmod("/var/lib/secrets/root-password", 0o600) - except Exception as exc: - logger.warning("write root-password error: %s", exc) - errors.append("write_root_password_failed") - - # Clear only the locked keyring databases, leaving the directory and 'default' pointer intact. - keyring_dir = "/home/free/.local/share/keyrings" - keyring_files = glob.glob(os.path.join(keyring_dir, "*.keyring")) - for kf in keyring_files: - try: - os.remove(kf) - except OSError as exc: - logger.warning("keyring wipe error for %s: %s", kf, exc) - errors.append("keyring_wipe_failed") + logger.warning("security reset: set %s password failed: %s", tag, exc) + errors.append(f"set_{tag}_password_failed") # The user performed a full security reset — the banner's purpose is served. try: @@ -5753,83 +5604,29 @@ async def api_security_verify_integrity(): class ChangePasswordRequest(BaseModel): + current_password: str new_password: str confirm_password: str @app.post("/api/change-password") async def api_change_password(req: ChangePasswordRequest): - """Change the system 'free' user password. + """Change the system 'free' user password (also the Hub login password). - Updates /etc/shadow via chpasswd and writes the new password to - /var/lib/secrets/free-password so the Hub credentials view stays in sync. - """ + Updates /etc/shadow via system_password.set_free_password, which also keeps + the /var/lib/secrets/free-password display copy in sync.""" + if not sp.verify_system_password(req.current_password): + raise HTTPException(status_code=403, detail="Current password is incorrect.") if not req.new_password: raise HTTPException(status_code=400, detail="New password must not be empty.") if req.new_password != req.confirm_password: raise HTTPException(status_code=400, detail="Passwords do not match.") if len(req.new_password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") - - # Locate chpasswd binary (NixOS puts it in the Nix store, not /usr/bin) - chpasswd_bin = ( - shutil.which("chpasswd") - or ("/run/current-system/sw/bin/chpasswd" - if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None) - ) - if chpasswd_bin is None: - raise HTTPException( - status_code=500, - detail="chpasswd binary not found. Cannot update system password.", - ) - - # Update /etc/shadow via chpasswd try: - result = subprocess.run( - [chpasswd_bin], - input=f"free:{req.new_password}", - capture_output=True, - text=True, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() or "chpasswd failed." - raise HTTPException(status_code=500, detail=detail) - except HTTPException: - raise - except Exception as exc: - logger.error("Failed to update system password: %s", exc) - raise HTTPException(status_code=500, detail="Failed to update system password.") - - # Write new password to secrets file so Hub credentials stay in sync - try: - os.makedirs(os.path.dirname(FREE_PASSWORD_FILE), exist_ok=True) - # Write secure web-only hash; sync system shadow via chpasswd (memory) - try: - import subprocess - subprocess.run( - ["chpasswd"], - input=f"free:{req.new_password}\n".encode(), - check=True, - capture_output=True, - ) - except Exception as exc: - # Log but do not block; web hash is the critical fix - pass - with open(FREE_PASSWORD_FILE_WEB, "w") as f: - f.write(_hash_password(req.new_password)) - os.chmod(FREE_PASSWORD_FILE_WEB, 0o600) - except Exception as exc: - logger.error("Failed to write secrets file: %s", exc) - raise HTTPException(status_code=500, detail="Failed to write secrets file.") - - # Clear only the locked keyring databases, leaving the directory and 'default' pointer intact. - keyring_dir = "/home/free/.local/share/keyrings" - for kf in glob.glob(os.path.join(keyring_dir, "*.keyring")): - try: - os.remove(kf) - except OSError: - pass # Non-fatal: keyring will be re-created on next login regardless - + await asyncio.get_event_loop().run_in_executor(None, sp.set_free_password, req.new_password) + except subprocess.CalledProcessError as exc: + raise HTTPException(status_code=500, detail=(exc.stderr or "chpasswd failed").strip()) return {"ok": True} diff --git a/app/sovran_systemsos_web/static/css/modals.css b/app/sovran_systemsos_web/static/css/modals.css index 0ec3792..5457916 100644 --- a/app/sovran_systemsos_web/static/css/modals.css +++ b/app/sovran_systemsos_web/static/css/modals.css @@ -1520,3 +1520,6 @@ margin-top: 10px; line-height: 1.5; } + +/* password cleanup: display copy changed outside the Hub */ +.creds-value.creds-unknown { font-style: italic; opacity: 0.75; } diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 7f8b1a8..d90d5f7 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -34,6 +34,10 @@ function _renderCredsHtml(credentials, unit) { html += '
' + escHtml(cred.label) + '
' + qrBlock + '
'; continue; } + if (cred.unknown) { + html += '
' + escHtml(cred.label) + '
' + escHtml(cred.hint || "Changed outside the Hub — sign out and back in to show it here.") + '
'; + continue; + } var displayValue = linkify(cred.value); html += '
' + escHtml(cred.label) + '
' + qrBlock + '
' + displayValue + '
'; } @@ -1293,6 +1297,11 @@ function openSystemChangePasswordModal(unit, name, icon) { '
🔑 Change Free Account & Hub Login Password
' + '
This updates the password for the free user account. This is also your Sovran Hub login password — both will change.
' + '' + + '
' + + '
' + + '' + + '' + + '
' + '
' + '
' + '' + @@ -1315,6 +1324,13 @@ function openSystemChangePasswordModal(unit, name, icon) { openServiceDetailModal(unit, name, icon); }); + document.getElementById("sys-chpw-current-toggle").addEventListener("click", function() { + var inp = document.getElementById("sys-chpw-current"); + var isHidden = inp.type === "password"; + inp.type = isHidden ? "text" : "password"; + this.textContent = isHidden ? "👁‍🗨" : "👁"; + }); + document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() { var inp = document.getElementById("sys-chpw-new"); var isHidden = inp.type === "password"; @@ -1332,12 +1348,13 @@ function openSystemChangePasswordModal(unit, name, icon) { document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() { var submitBtn = document.getElementById("sys-chpw-submit-btn"); var resultEl = document.getElementById("sys-chpw-result"); + var currentPassword = document.getElementById("sys-chpw-current").value || ""; var newPassword = document.getElementById("sys-chpw-new").value || ""; var confirmPassword = document.getElementById("sys-chpw-confirm").value || ""; - if (!newPassword || !confirmPassword) { + if (!currentPassword || !newPassword || !confirmPassword) { resultEl.className = "matrix-form-result error"; - resultEl.textContent = "Both password fields are required."; + resultEl.textContent = "All password fields are required."; return; } @@ -1362,7 +1379,7 @@ function openSystemChangePasswordModal(unit, name, icon) { await apiFetch("/api/change-password", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword }) + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword, confirm_password: confirmPassword }) }); resultEl.className = "matrix-form-result success"; resultEl.textContent = "✅ Free account & Hub login password changed successfully."; diff --git a/app/sovran_systemsos_web/system_password.py b/app/sovran_systemsos_web/system_password.py new file mode 100644 index 0000000..e5c8614 --- /dev/null +++ b/app/sovran_systemsos_web/system_password.py @@ -0,0 +1,231 @@ +"""system_password.py -- the one place that knows about the 'free' (and root) password. + +Rules + * /etc/shadow is the ONLY source of truth for authentication: Hub login == GDM login. + * /var/lib/secrets/free-password (0600 root) is a DISPLAY copy for the Hub's credentials + view. Only set_free_password() writes it, and it is verified against /etc/shadow before + it is shown, so the Hub can never show a stale password. + * Legacy / unknown-password machines go through the *migration* flow the onboarding already + has: generate a new diceware password, show it, and apply it on acknowledgement. Until + then the old password keeps working everywhere (shadow is untouched). + * No keyring code anywhere: the GNOME login keyring is decoupled from the login password + (blank-password keyring, pam_gnome_keyring off), so nothing has to be wiped or re-keyed. + +Needs libxcrypt. On NixOS the Hub service gets: environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1" +""" +from __future__ import annotations + +import ctypes +import ctypes.util +import hmac +import os +import secrets +import subprocess +import threading +from typing import Callable + +USER = "free" +SECRETS_DIR = "/var/lib/secrets" +DISPLAY_FILE = f"{SECRETS_DIR}/free-password" +ROOT_DISPLAY_FILE = f"{SECRETS_DIR}/root-password" +MIGRATION_FILE = f"{SECRETS_DIR}/free-password-migration-newpass" # same path the onboarding UI already uses +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 + + +# ── low level ────────────────────────────────────────────────────────────── + +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 + + +def shadow_hash(user: str = USER) -> str | None: + """Password hash from /etc/shadow, or None if the account has none / is locked.""" + try: + with open("/etc/shadow", encoding="utf-8") as f: + for line in f: + fields = line.rstrip("\n").split(":") + if fields[0] == user: + h = fields[1] + return h if h and h[0] not in "!*" else None + except OSError: + return None + return None + + +def verify_system_password(password: str, user: str = USER) -> bool: + """True iff `password` is the account's current login password + (yescrypt, sha512crypt, ... whatever chpasswd produced).""" + 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()) + + +def _write_secret(path: str, value: str) -> None: + os.makedirs(SECRETS_DIR, mode=0o700, exist_ok=True) + tmp = path + ".tmp" + with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as f: + f.write(value + "\n") + os.replace(tmp, path) + + +def _read_secret(path: str) -> str | None: + try: + with open(path, encoding="utf-8") as f: + v = f.read().rstrip("\n") + return v or None + except OSError: + return None + + +def _chpasswd(user: str, password: str) -> None: + import shutil + bin_ = shutil.which("chpasswd") or "/run/current-system/sw/bin/chpasswd" + subprocess.run([bin_], input=f"{user}:{password}\n", text=True, check=True, capture_output=True) + + +# ── the single writers ───────────────────────────────────────────────────── + +def set_free_password(new_password: str) -> None: + """System password first, then the display copy. Used by: change-password, reset, + migration acknowledgement, the CLI.""" + if not new_password: + raise ValueError("empty password") + _chpasswd(USER, new_password) + _write_secret(DISPLAY_FILE, new_password) + + +def set_root_password(new_password: str) -> None: + """Same contract for root (the credentials view lists it too).""" + if not new_password: + raise ValueError("empty password") + _chpasswd("root", new_password) + _write_secret(ROOT_DISPLAY_FILE, new_password) + + +# ── what the Hub shows ───────────────────────────────────────────────────── + +def read_display_password() -> str | None: + """The 'free' password to SHOW -- only if it really is the current one.""" + pw = _read_secret(DISPLAY_FILE) + return pw if pw and verify_system_password(pw) else None + + +def read_root_display_password() -> str | None: + pw = _read_secret(ROOT_DISPLAY_FILE) + if not pw or ":" in pw: # ':' = scrypt hash written by older reset code -> not showable + return None + return pw if verify_system_password(pw, "root") else None + + +def _display_state(path: str) -> str: + """"missing" | "legacy" | "present" -- "present" only means a plaintext copy + exists; it can still be stale (use read_display_password() to check that).""" + pw = _read_secret(path) + if pw is None: + return "missing" + return "legacy" if ":" in pw else "present" + + +# ── migration flow (legacy machines) ──────────────────────────────────────── + +def migration_pending() -> str | None: + """The generated password awaiting acknowledgement, if any.""" + return _read_secret(MIGRATION_FILE) + + +def ensure_migration_pending(generate: Callable[[], str]) -> bool: + """Legacy detection. Call it where onboarding status is computed. + + Condition: the account HAS a password (so this is not the first boot) but the Hub + has NO display copy of it at all (file missing or still the old hash format). + Then generate a new password once and park it for the onboarding "migration + password" step. Nothing is applied until the user acknowledges, so the old + password keeps working at GDM and in the Hub. Returns True when a migration is + pending (caller re-opens onboarding). + + A copy that exists but merely fails to verify (password changed in GNOME + Settings, `passwd`, or the pre-upgrade Hub) is DRIFT, not a legacy install: + it never triggers a migration and never changes any password -- it is healed + automatically at the next login via record_known_password(pw, only_if_stale=True). + + Never fires on the live ISO (/iso) or while shadow is still locked.""" + if os.path.exists(LIVE_ISO_MARKER): + return False + if migration_pending(): + return True + if shadow_hash() is None: + return False + if _display_state(DISPLAY_FILE) == "present": + return False # copy exists: current, or just stale (drift) + _write_secret(MIGRATION_FILE, generate()) + return True + + +def acknowledge_migration() -> bool: + """User has written the new password down: make it real, then clear the pending file.""" + pw = migration_pending() + if not pw: + return False + set_free_password(pw) + os.remove(MIGRATION_FILE) + return True + + +def record_known_password(password: str, only_if_stale: bool = False) -> bool: + """"Keep my current password" (migration step): if the user types the correct + current password, store it as the display copy and cancel the migration. + + only_if_stale=True is the login-handler heal: write ONLY when an existing + plaintext copy is stale, never create a missing one (a missing copy is the + legacy case and belongs to the migration flow).""" + if not verify_system_password(password): + return False + if only_if_stale and _display_state(DISPLAY_FILE) != "present": + return False + if _read_secret(DISPLAY_FILE) != password: + _write_secret(DISPLAY_FILE, password) + try: + os.remove(MIGRATION_FILE) + except FileNotFoundError: + pass + return True + + +def generate_diceware_password(words: list[str] | None = None, n: int = 4) -> str: + """word-word-word-word-NN, same shape the installer/reset produce (CSPRNG).""" + words = words or ("apple barn brook cabin cedar cloud coral crane delta eagle ember fern field " + "flame flora flint frost grove haven hedge holly heron jade juniper kelp larch " + "lemon lilac linden loch lotus maple marsh meadow mist mossy mount oak ocean " + "olive petal pine pixel plum pond prism quartz raven ridge river robin rocky " + "rose rowan sage sand sierra silver slate snow solar spark spruce stone storm " + "summit swift thorn tide timber torch trout vale vault vine walnut wave willow " + "wren amber aspen birch blaze bloom bluff coast copper crest dune elder fjord " + "forge glade glen glow gulf").split() + return "-".join(secrets.choice(words) for _ in range(n)) + f"-{secrets.randbelow(100):02d}" + + +if __name__ == "__main__": # tiny CLI for credentials.nix: echo NEW | system_password.py set-free + import sys + cmd = sys.argv[1] if len(sys.argv) > 1 else "" + if cmd in ("set-free", "set-root"): + pw = sys.stdin.readline().rstrip("\n") + (set_free_password if cmd == "set-free" else set_root_password)(pw) + print("ok") + elif cmd == "verify": + sys.exit(0 if verify_system_password(sys.stdin.readline().rstrip("\n")) else 1) + else: + print("usage: system_password.py set-free|set-root|verify (password on stdin)", file=sys.stderr) + sys.exit(2) diff --git a/configuration.nix b/configuration.nix index aed8325..08e44ba 100644 --- a/configuration.nix +++ b/configuration.nix @@ -74,18 +74,6 @@ services.printing.enable = true; systemd.enableEmergencyMode = false; environment.gnome.excludePackages = [ pkgs.gnome-tour ]; - security.pam.services.gdm-password.enableGnomeKeyring = true; - security.pam.services.gdm-autologin.enableGnomeKeyring = true; - - # Declaratively guarantee the GNOME Keyring default pointer exists. - # Defining the full path ensures root doesn't accidentally lock the user out of .local - systemd.tmpfiles.rules = [ - "d /home/free/.local 0700 free users -" - "d /home/free/.local/share 0700 free users -" - "d /home/free/.local/share/keyrings 0700 free users -" - "f /home/free/.local/share/keyrings/default 0600 free users - login\n" - ]; - # ── Audio ────────────────────────────────────────────────── services.pulseaudio.enable = false; diff --git a/modules/credentials.nix b/modules/credentials.nix index b1d9bdf..c261c89 100644 --- a/modules/credentials.nix +++ b/modules/credentials.nix @@ -33,9 +33,6 @@ let echo "$NEW_PASS" > "$SECRET_FILE" chmod 600 "$SECRET_FILE" echo "Password for 'free' updated and saved." - # Delete the old GNOME Keyring databases so a fresh one is created on next GDM login. - rm -f /home/free/.local/share/keyrings/*.keyring - echo "GNOME Keyring files cleared — a fresh keyring will be created on next login." ''; in { @@ -76,172 +73,4 @@ in command passwd $argv end ''; - - # ── 1. Auto-Generate Root Password (Runs once) ───────────── - systemd.services.root-password-setup = { - description = "Generate and set a random root password"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - path = [ pkgs.shadow pkgs.coreutils ]; - script = '' - set -euo pipefail - SECRET_FILE="/var/lib/secrets/root-password" - if [ ! -f "$SECRET_FILE" ]; then - mkdir -p /var/lib/secrets - # Generate a diceware-style passphrase: word-word-word-word-NN - WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \ - fern field flame flora flint frost grove haven hedge holly heron \ - jade juniper kelp larch lemon lilac linden loch lotus maple marsh \ - meadow mist mossy mount oak ocean olive petal pine pixel plum pond \ - prism quartz raven ridge river robin rocky rose rowan sage sand \ - sierra silver slate snow solar spark spruce stone storm summit \ - swift thorn tide timber torch trout vale vault vine walnut wave \ - willow wren amber aspen birch blaze bloom bluff coast copper crest \ - dune elder fjord forge glade glen glow gulf" - WORD_ARRAY=($WORDS) - COUNT=''${#WORD_ARRAY[@]} - W1=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W2=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W3=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W4=''${WORD_ARRAY[$((RANDOM % COUNT))]} - DIGIT=$(printf '%02d' $((RANDOM % 100))) - ROOT_PASS="$W1-$W2-$W3-$W4-$DIGIT" - echo "$ROOT_PASS" > "$SECRET_FILE" - chmod 600 "$SECRET_FILE" - fi - # If the file contains a scrypt hash (salt:hash), skip chpasswd — the - # password was already set via the Hub security reset endpoint and this - # service is only re-running as a manual recovery step. - CONTENT="$(cat "$SECRET_FILE")" - if echo "$CONTENT" | grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$'; then - echo "root-password-setup: stored value is already hashed — skipping chpasswd" >&2 - else - echo "root:$CONTENT" | chpasswd - fi - ''; - }; - - # ── 1b. Generate random 'free' password on first boot ────── - systemd.services.free-password-setup = { - description = "Generate and set a random 'free' user password"; - wantedBy = [ "multi-user.target" ]; - before = [ "display-manager.service" ]; - after = [ "systemd-user-sessions.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - path = [ pkgs.shadow pkgs.coreutils ]; - script = '' - set -euo pipefail - SECRET_FILE="/var/lib/secrets/free-password" - PENDING_FILE="/var/lib/secrets/free-password-migration-pending" - - if [ -f "$SECRET_FILE" ]; then - echo "free:$(cat "$SECRET_FILE")" | chpasswd - exit 0 - fi - - SHADOW_HASH="" - while IFS=: read -r user hash _; do - if [ "$user" = "free" ]; then - SHADOW_HASH="$hash" - break - fi - done < /etc/shadow - - HAS_REAL_HASH=0 - case "$SHADOW_HASH" in - ""|"!"|"*"|"!!"|"!"*|"*"*) - HAS_REAL_HASH=0 - ;; - *) - HAS_REAL_HASH=1 - ;; - esac - - if [ "$HAS_REAL_HASH" -eq 1 ]; then - mkdir -p /var/lib/secrets - touch "$PENDING_FILE" - chmod 600 "$PENDING_FILE" - exit 0 - fi - - mkdir -p /var/lib/secrets - # Generate a diceware-style passphrase: word-word-word-word-NN - WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \ - fern field flame flora flint frost grove haven hedge holly heron \ - jade juniper kelp larch lemon lilac linden loch lotus maple marsh \ - meadow mist mossy mount oak ocean olive petal pine pixel plum pond \ - prism quartz raven ridge river robin rocky rose rowan sage sand \ - sierra silver slate snow solar spark spruce stone storm summit \ - swift thorn tide timber torch trout vale vault vine walnut wave \ - willow wren amber aspen birch blaze bloom bluff coast copper crest \ - dune elder fjord forge glade glen glow gulf" - WORD_ARRAY=($WORDS) - COUNT=''${#WORD_ARRAY[@]} - W1=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W2=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W3=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W4=''${WORD_ARRAY[$((RANDOM % COUNT))]} - DIGIT=$(printf '%02d' $((RANDOM % 100))) - FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT" - echo "$FREE_PASS" > "$SECRET_FILE" - chmod 600 "$SECRET_FILE" - echo "free:$FREE_PASS" | chpasswd - ''; - }; - - systemd.services.free-password-migration = { - description = "Generate and set 'free' password for migrated machines"; - wantedBy = [ "multi-user.target" ]; - before = [ "display-manager.service" ]; - after = [ "systemd-user-sessions.service" "free-password-setup.service" ]; - requires = [ "free-password-setup.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - path = [ pkgs.shadow pkgs.coreutils ]; - script = '' - set -euo pipefail - - PENDING_FILE="/var/lib/secrets/free-password-migration-pending" - SECRET_FILE="/var/lib/secrets/free-password" - NEWPASS_FILE="/var/lib/secrets/free-password-migration-newpass" - - [ -f "$PENDING_FILE" ] || exit 0 - - mkdir -p /var/lib/secrets - - WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \ - fern field flame flora flint frost grove haven hedge holly heron \ - jade juniper kelp larch lemon lilac linden loch lotus maple marsh \ - meadow mist mossy mount oak ocean olive petal pine pixel plum pond \ - prism quartz raven ridge river robin rocky rose rowan sage sand \ - sierra silver slate snow solar spark spruce stone storm summit \ - swift thorn tide timber torch trout vale vault vine walnut wave \ - willow wren amber aspen birch blaze bloom bluff coast copper crest \ - dune elder fjord forge glade glen glow gulf" - WORD_ARRAY=($WORDS) - COUNT=''${#WORD_ARRAY[@]} - W1=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W2=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W3=''${WORD_ARRAY[$((RANDOM % COUNT))]} - W4=''${WORD_ARRAY[$((RANDOM % COUNT))]} - DIGIT=$(printf '%02d' $((RANDOM % 100))) - FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT" - - printf '%s\n' "$FREE_PASS" > "$SECRET_FILE" - chmod 600 "$SECRET_FILE" - - printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE" - chmod 600 "$NEWPASS_FILE" - rm -f "$PENDING_FILE" - ''; - }; - } diff --git a/modules/modules.nix b/modules/modules.nix index b7b8098..d5f0d15 100755 --- a/modules/modules.nix +++ b/modules/modules.nix @@ -21,6 +21,7 @@ # ── Always on (no flag) ─────────────────────────────────── ./php.nix ./credentials.nix + ./password-model.nix # ── Services (default ON — disable in custom.nix) ───────── ./synapse.nix diff --git a/modules/password-model.nix b/modules/password-model.nix new file mode 100644 index 0000000..bf93625 --- /dev/null +++ b/modules/password-model.nix @@ -0,0 +1,127 @@ +{ config, pkgs, lib, ... }: + +# Sovran_SystemsOS -- password model cleanup (GNOME 50 / gnome-keyring 50) +# +# * /etc/shadow is the only source of truth for authentication (Hub login == GDM login). +# * /var/lib/secrets/{free,root}-password are root-only DISPLAY copies for the Hub, written +# by exactly one module (system_password.py) and verified against shadow before shown. +# * The GNOME login keyring is decoupled from the login password: it is created with a +# blank password and pam_gnome_keyring never hands the login password to the daemon. +# => no "unlock your login keyring" prompts, ever (also on the autologin live ISO); +# Brave's cookie/password key survives every password change; no *.keyring wiping. +# * Legacy machines (password unknown to the Hub) are handled by the Hub's existing +# onboarding "migration password" step -- not by a boot service any more. +# +# Import from configuration.nix and DELETE these lines there: +# security.pam.services.gdm-password.enableGnomeKeyring = true; +# security.pam.services.gdm-autologin.enableGnomeKeyring = true; +# the four systemd.tmpfiles.rules for /home/free/.local/share/keyrings/{,default} +# and in modules/credentials.nix delete root-password-setup, free-password-setup, +# free-password-migration and the `rm -f /home/free/.local/share/keyrings/*.keyring` lines. + +let + user = "free"; + home = "/home/${user}"; + + # A login keyring with a blank password. gnome-keyring stores those in this textual + # format and auto-unlocks them on demand without any prompt (verified on 48; the rule in + # gkm-secret-collection.c that picks the textual format for a blank master is unchanged + # in 50 and the 50.0 release notes contain no behavioural changes). + blankLoginKeyring = pkgs.writeText "login.keyring" '' + [keyring] + display-name=login + ctime=0 + mtime=0 + lock-on-idle=false + lock-after=false + ''; + + # 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 + # writes it. This replaces the old services, which re-applied the file on EVERY boot + # and silently reverted any password changed elsewhere. + # On already-installed machines the case below matches `*)` (real hash) -> no-op: + # an upgrade can never change or generate a password. + applyOnce = { account, fallback ? false }: '' + set -euo pipefail + f=/var/lib/secrets/${account}-password + hash="$(getent shadow ${account} | cut -d: -f2 || true)" + case "$hash" in + ""|"!"*|"*"*) + if [ -s "$f" ] && ! grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$' "$f"; then + echo "${account}:$(head -n1 "$f")" | chpasswd + ${lib.optionalString fallback '' + else + # Installer left no usable file: generate one so GDM is always reachable + # (the user reads it in the Hub). Never runs once the account has a password. + W="apple barn brook cabin cedar cloud coral crane delta eagle ember fern field flame + flora flint frost grove haven hedge holly heron jade juniper kelp larch lemon lilac + linden loch lotus maple marsh meadow mist mossy mount oak ocean olive petal pine + pixel plum pond prism quartz raven ridge river robin rocky rose rowan sage sand + sierra silver slate snow solar spark spruce stone storm summit swift thorn tide + timber torch trout vale vault vine walnut wave willow wren" + pw=$(for i in 1 2 3 4; do set -- $W; eval echo \${$((RANDOM % $# + 1))}; done | paste -sd- -)-$(printf '%02d' $((RANDOM % 100))) + umask 077; printf '%s\n' "$pw" > "$f" + echo "${account}:$pw" | chpasswd + ''} + fi ;; + *) ;; # password already set: never touch it here again + esac + ''; +in +{ + # ── 1. Keyring: never learns the login password ─────────────────────────── + # nixpkgs' gnome-keyring module turns this on for `login` (gdm-password includes it). + # If the daemon ever receives the login password it re-encrypts the blank keyring with + # it on the next save -- so it must be off everywhere. + security.pam.services.login.enableGnomeKeyring = lib.mkForce false; + security.pam.services.gdm-password.enableGnomeKeyring = lib.mkForce false; + security.pam.services.gdm-autologin.enableGnomeKeyring = lib.mkForce false; + security.pam.services.passwd.enableGnomeKeyring = lib.mkForce false; + + # Seed the blank keyring. `C` copies only if the file does not exist, so an existing + # keyring is never overwritten -- which is also what makes a reset's `rm *.keyring` + # harmless: the next boot re-seeds a fresh blank keyring. + systemd.tmpfiles.rules = [ + "d ${home}/.local 0700 ${user} users -" + "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}" + ]; + + # One-time migration for machines that already have a keyring encrypted with the + # (old) login password: move it aside and seed the blank one. Runs before GDM; the old + # file is kept. Same cost users pay today on every password change (Brave logs you out + # of sites once) -- but only once, ever. + system.activationScripts.sovranBlankKeyring = lib.stringAfter [ "users" ] '' + d=${home}/.local/share/keyrings + if [ -f "$d/login.keyring" ] && ! head -c 9 "$d/login.keyring" | grep -q '^\[keyring\]'; then + mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)" + install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring" + fi + ''; + + # ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords" + # service card is bound to root-password-setup.service) ─────────────────────── + systemd.services.free-password-setup = { + description = "Apply the installer's 'free' password (first boot only)"; + wantedBy = [ "multi-user.target" ]; + before = [ "display-manager.service" ]; + serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; + path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ]; + script = applyOnce { account = user; fallback = true; }; + }; + systemd.services.root-password-setup = { + description = "Apply the installer's root password (first boot only)"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; + path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ]; + script = applyOnce { account = "root"; }; + }; + # Legacy detection now lives in the Hub (system_password.ensure_migration_pending). + systemd.services.free-password-migration.enable = false; + + # ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ─────── + systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1"; +}