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.
This commit is contained in:
naturallaw777 authored and naturallaw77 committed 2026-10-16 12:25:16 -05:00
1 parent 5e56b07465
commit 4a25ef24d1
8 files changed
+430 -437

No files matched your search

+48 -251
View File
@@ -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}
@@ -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; }
@@ -34,6 +34,10 @@ function _renderCredsHtml(credentials, unit) {
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '</div>';
continue;
}
if (cred.unknown) {
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div><div class="creds-value-wrap"><div class="creds-value creds-unknown">' + escHtml(cred.hint || "Changed outside the Hub — sign out and back in to show it here.") + '</div></div></div>';
continue;
}
var displayValue = linkify(cred.value);
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
}
@@ -1293,6 +1297,11 @@ function openSystemChangePasswordModal(unit, name, icon) {
'<div class="sys-chpw-title">🔑 Change Free Account &amp; Hub Login Password</div>' +
'<div class="sys-chpw-desc">This updates the password for the <strong>free</strong> user account. <strong>This is also your Sovran Hub login password</strong> — both will change.</div>' +
'</div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-current">Current Password</label>' +
'<div class="pw-input-wrap">' +
'<input class="matrix-form-input" type="password" id="sys-chpw-current" placeholder="Current password" autocomplete="current-password">' +
'<button type="button" class="pw-toggle-btn" id="sys-chpw-current-toggle" aria-label="Toggle password visibility">👁</button>' +
'</div></div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
'<div class="pw-input-wrap">' +
'<input class="matrix-form-input" type="password" id="sys-chpw-new" placeholder="New strong password" autocomplete="new-password">' +
@@ -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.";
+231
View File
@@ -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)
-12
View File
@@ -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;
-171
View File
@@ -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"
'';
};
}
+1
View File
@@ -21,6 +21,7 @@
# ── Always on (no flag) ───────────────────────────────────
./php.nix
./credentials.nix
./password-model.nix
# ── Services (default ON — disable in custom.nix) ─────────
./synapse.nix
+127
View File
@@ -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";
}