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:
1 parent
5e56b07465
commit
4a25ef24d1
8 files changed
+430
-437
No files matched your search
@@ -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 & 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.";
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
./credentials.nix
|
||||
./password-model.nix
|
||||
|
||||
# ── Services (default ON — disable in custom.nix) ─────────
|
||||
./synapse.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";
|
||||
}
|
||||
Reference in new issue
Block a user