From 17f89fa773511ca9cd65e888e787ed1467da020e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:08:25 +0000 Subject: [PATCH] fix: disable auto-login, diceware passwords, improved security reset UX, fix GNOME keyring Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/82a54a25-4844-4a41-afcc-c034cebbd6ed Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 126 +++++++++++++++++- .../static/css/security.css | 54 ++++++++ .../static/js/security.js | 42 +++++- app/sovran_systemsos_web/templates/index.html | 19 ++- configuration.nix | 4 +- modules/credentials.nix | 68 ++++++---- 6 files changed, 268 insertions(+), 45 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index fce9ebb..6660154 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -429,6 +429,32 @@ SERVICE_DESCRIPTIONS: dict[str, str] = { ), } +# ── Diceware password generation ───────────────────────────────── + +_DICEWARE_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", +] + + +def _generate_diceware_password() -> str: + """Generate a human-readable diceware-style passphrase: word-word-word-N.""" + import secrets as _secrets + words = [_secrets.choice(_DICEWARE_WORDS) for _ in range(3)] + digit = _secrets.randbelow(10) + return "-".join(words) + f"-{digit}" + + # ── App setup ──────────────────────────────────────────────────── _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -3040,7 +3066,10 @@ async def api_security_reset(): Wipes secrets, LND wallet, SSH keys, drops databases, removes app configs, clears Vaultwarden data, removes the onboarding-complete flag so onboarding - re-runs, and reboots the system. + re-runs. Generates a new diceware password for the 'free' and 'root' users, + deletes the GNOME Keyring so a fresh one is created on next GDM login, and + returns the new password to the caller. Does NOT reboot — the caller must + trigger a separate POST /api/reboot after showing the password to the user. """ wipe_paths = [ "/var/lib/secrets", @@ -3099,13 +3128,79 @@ async def api_security_reset(): except Exception as exc: errors.append(f"mariadb wipe: {exc}") - # Reboot - try: - subprocess.Popen(["systemctl", "reboot"]) - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Reboot failed: {exc}") + # Generate new diceware passwords + new_free_password = _generate_diceware_password() + new_root_password = _generate_diceware_password() - return {"ok": True, "errors": errors} + # 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 + try: + result = subprocess.run( + [chpasswd_bin], + input=f"free:{new_free_password}", + capture_output=True, text=True, + ) + if result.returncode != 0: + errors.append(f"chpasswd free: {(result.stderr or result.stdout).strip()}") + except Exception as exc: + errors.append(f"chpasswd free: {exc}") + + # Set root password + try: + result = subprocess.run( + [chpasswd_bin], + input=f"root:{new_root_password}", + capture_output=True, text=True, + ) + if result.returncode != 0: + errors.append(f"chpasswd root: {(result.stderr or result.stdout).strip()}") + except Exception as exc: + errors.append(f"chpasswd root: {exc}") + else: + errors.append("chpasswd not found; passwords not reset") + + # Write new passwords to secrets files + try: + os.makedirs("/var/lib/secrets", exist_ok=True) + with open("/var/lib/secrets/free-password", "w") as f: + f.write(new_free_password) + os.chmod("/var/lib/secrets/free-password", 0o600) + except Exception as exc: + errors.append(f"write free-password: {exc}") + + try: + os.makedirs("/var/lib/secrets", exist_ok=True) + with open("/var/lib/secrets/root-password", "w") as f: + f.write(new_root_password) + os.chmod("/var/lib/secrets/root-password", 0o600) + except Exception as exc: + errors.append(f"write root-password: {exc}") + + # Delete GNOME Keyring files so a fresh keyring is created with the new + # password on the next GDM login (PAM unlocks it automatically). + keyring_dir = "/home/free/.local/share/keyrings" + try: + if os.path.isdir(keyring_dir): + for entry in os.listdir(keyring_dir): + entry_path = os.path.join(keyring_dir, entry) + try: + if os.path.isfile(entry_path) or os.path.islink(entry_path): + os.unlink(entry_path) + elif os.path.isdir(entry_path): + shutil.rmtree(entry_path, ignore_errors=True) + except Exception: + pass + except Exception as exc: + errors.append(f"keyring wipe: {exc}") + + return {"ok": True, "new_password": new_free_password, "errors": errors} @app.post("/api/security/verify-integrity") @@ -3265,6 +3360,23 @@ async def api_change_password(req: ChangePasswordRequest): except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}") + # Delete GNOME Keyring files so a fresh keyring is created with the new + # password on the next GDM login (PAM will unlock it automatically). + keyring_dir = "/home/free/.local/share/keyrings" + try: + if os.path.isdir(keyring_dir): + for entry in os.listdir(keyring_dir): + entry_path = os.path.join(keyring_dir, entry) + try: + if os.path.isfile(entry_path) or os.path.islink(entry_path): + os.unlink(entry_path) + elif os.path.isdir(entry_path): + shutil.rmtree(entry_path, ignore_errors=True) + except Exception: + pass + except Exception: + pass # Non-fatal: keyring will be re-created on next login regardless + return {"ok": True} diff --git a/app/sovran_systemsos_web/static/css/security.css b/app/sovran_systemsos_web/static/css/security.css index d7ff22a..f40dea6 100644 --- a/app/sovran_systemsos_web/static/css/security.css +++ b/app/sovran_systemsos_web/static/css/security.css @@ -242,6 +242,60 @@ margin-bottom: 16px; } +/* ── Phase 2: password display box ──────────────────────────────── */ + +.security-reset-password-label { + font-size: 0.88rem; + color: var(--text-secondary); + margin: 16px 0 8px 0; +} + +.security-reset-password-box { + font-family: monospace; + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); + background: rgba(109, 191, 139, 0.10); + border: 1.5px solid rgba(109, 191, 139, 0.35); + border-radius: 8px; + padding: 14px 24px; + letter-spacing: 0.04em; + text-align: center; + word-break: break-all; + margin-bottom: 16px; + min-width: 260px; +} + +.security-reset-password-warning { + font-size: 0.84rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 20px; + text-align: center; +} + +.security-reset-reboot-btn { + background-color: #6DBF8B; + color: #0a0c0b; + border: none; + border-radius: 7px; + padding: 11px 22px; + font-size: 0.88rem; + font-weight: 700; + cursor: pointer; + transition: background-color 0.15s, opacity 0.15s; + white-space: nowrap; +} + +.security-reset-reboot-btn:hover:not(:disabled) { + background-color: #5aab78; +} + +.security-reset-reboot-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ── First-login security banner ─────────────────────────────────── */ .security-first-login-banner { diff --git a/app/sovran_systemsos_web/static/js/security.js b/app/sovran_systemsos_web/static/js/security.js index 13cac7c..a2a1b29 100644 --- a/app/sovran_systemsos_web/static/js/security.js +++ b/app/sovran_systemsos_web/static/js/security.js @@ -128,11 +128,43 @@ function openSecurityModal() { if (resetStatus) { resetStatus.textContent = "Running security reset\u2026"; resetStatus.className = "security-status-msg security-status-info"; } try { - await apiFetch("/api/security/reset", { method: "POST" }); - if ($secResetStep) $secResetStep.textContent = "Reset complete. Rebooting now\u2026"; - if (resetStatus) { resetStatus.textContent = "\u2713 Reset complete. Rebooting\u2026"; resetStatus.className = "security-status-msg security-status-ok"; } - if ($rebootOverlay) $rebootOverlay.classList.add("visible"); - setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + var data = await apiFetch("/api/security/reset", { method: "POST" }); + + // Switch to Phase 2: show the new password and wait for user confirmation + var phase1 = document.getElementById("security-reset-phase1"); + var phase2 = document.getElementById("security-reset-phase2"); + var passwordBox = document.getElementById("security-reset-new-password"); + var rebootBtn = document.getElementById("security-reset-reboot-btn"); + + if (phase1) phase1.style.display = "none"; + if (phase2) phase2.style.display = ""; + if (passwordBox && data.new_password) passwordBox.textContent = data.new_password; + + if (rebootBtn) { + // Keep button disabled for 5 seconds to prevent accidental clicks + var countdown = 5; + rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")"; + var timer = setInterval(function() { + countdown--; + if (countdown <= 0) { + clearInterval(timer); + rebootBtn.disabled = false; + rebootBtn.textContent = "I have written down my new password \u2014 Reboot now"; + } else { + rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")"; + } + }, 1000); + + rebootBtn.addEventListener("click", async function() { + rebootBtn.disabled = true; + rebootBtn.textContent = "Rebooting\u2026"; + try { + await apiFetch("/api/reboot", { method: "POST" }); + } catch (_) {} + if ($rebootOverlay) $rebootOverlay.classList.add("visible"); + setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); + }, { once: true }); + } } catch (err) { if ($secResetOverlay) $secResetOverlay.classList.remove("visible"); if (resetStatus) { resetStatus.textContent = "\u2717 Error: " + (err.message || "Reset failed."); resetStatus.className = "security-status-msg security-status-error"; } diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index 1a3693d..c101dfa 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -212,13 +212,14 @@
-
+ +
🛡

Security Reset In Progress

⚠️ Wiping all data and credentials.
Do not power off your computer.
- This may take several minutes. The system will reboot automatically when complete. + This may take several minutes.

@@ -227,6 +228,20 @@

Erasing data and resetting credentials…

+ +
diff --git a/configuration.nix b/configuration.nix index 27db1c0..55032cc 100644 --- a/configuration.nix +++ b/configuration.nix @@ -88,8 +88,8 @@ extraGroups = [ "networkmanager" ]; }; - services.displayManager.autoLogin.enable = true; - services.displayManager.autoLogin.user = "free"; + services.displayManager.autoLogin.enable = false; + # services.displayManager.autoLogin.user = "free"; # Disabled — user logs in via GDM # ── Flatpak ──────────────────────────────────────────────── services.flatpak.enable = true; diff --git a/modules/credentials.nix b/modules/credentials.nix index 7824038..12246aa 100644 --- a/modules/credentials.nix +++ b/modules/credentials.nix @@ -33,8 +33,9 @@ let echo "$NEW_PASS" > "$SECRET_FILE" chmod 600 "$SECRET_FILE" echo "Password for 'free' updated and saved." - echo "$NEW_PASS" | ${pkgs.gnome-keyring}/bin/gnome-keyring-daemon --unlock || echo "Warning: GNOME Keyring re-key failed." >&2 - echo "GNOME Keyring re-keyed with new password." + # Delete the old GNOME Keyring so it is recreated with the new password on next GDM login. + rm -rf /home/free/.local/share/keyrings/* + echo "GNOME Keyring files cleared — a fresh keyring will be created on next login." ''; in { @@ -84,12 +85,28 @@ in Type = "oneshot"; RemainAfterExit = true; }; - path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ]; + path = [ pkgs.shadow pkgs.coreutils ]; script = '' SECRET_FILE="/var/lib/secrets/root-password" if [ ! -f "$SECRET_FILE" ]; then mkdir -p /var/lib/secrets - ROOT_PASS=$(pwgen -s 20 1) + # Generate a diceware-style passphrase: word-word-word-N + 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))]} + DIGIT=$((RANDOM % 10)) + ROOT_PASS="$W1-$W2-$W3-$DIGIT" echo "root:$ROOT_PASS" | chpasswd echo "$ROOT_PASS" > "$SECRET_FILE" chmod 600 "$SECRET_FILE" @@ -105,12 +122,28 @@ in Type = "oneshot"; RemainAfterExit = true; }; - path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ]; + path = [ pkgs.shadow pkgs.coreutils ]; script = '' SECRET_FILE="/var/lib/secrets/free-password" if [ ! -f "$SECRET_FILE" ]; then mkdir -p /var/lib/secrets - FREE_PASS=$(pwgen -s 20 1) + # Generate a diceware-style passphrase: word-word-word-N + 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))]} + DIGIT=$((RANDOM % 10)) + FREE_PASS="$W1-$W2-$W3-$DIGIT" echo "free:$FREE_PASS" | chpasswd echo "$FREE_PASS" > "$SECRET_FILE" chmod 600 "$SECRET_FILE" @@ -118,27 +151,4 @@ in ''; }; - # ── 2. Unlock GNOME Keyring on graphical session start ───── - systemd.services.gnome-keyring-unlock = { - description = "Unlock GNOME Keyring with stored free password"; - after = [ "free-password-setup.service" "display-manager.service" ]; - wants = [ "free-password-setup.service" ]; - wantedBy = [ "graphical-session.target" ]; - serviceConfig = { - Type = "oneshot"; - User = "free"; - ExecStartPre = "${pkgs.coreutils}/bin/sleep 3"; - }; - path = [ pkgs.gnome-keyring pkgs.coreutils ]; - script = '' - SECRET_FILE="/var/lib/secrets/free-password" - if [ -f "$SECRET_FILE" ]; then - gnome-keyring-daemon --unlock < "$SECRET_FILE" - echo "GNOME Keyring unlocked with stored password." - else - echo "No password file found, skipping keyring unlock." - fi - ''; - }; - }