Implement security overhaul: remove seal/legacy system, add Security modal and random passwords

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6e7593c4-f741-4ddc-9bce-8c558a4af014

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-09 01:58:42 +00:00
committed by GitHub
parent 477d265de8
commit 2fae4ccc79
13 changed files with 743 additions and 659 deletions

View File

@@ -61,10 +61,9 @@ REBOOT_COMMAND = ["reboot"]
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete" ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
# ── Legacy security check constants ────────────────────────────── # ── Security constants ────────────────────────────────────────────
SECURITY_STATUS_FILE = "/var/lib/sovran/security-status" SECURITY_BANNER_DISMISSED_FLAG = "/var/lib/sovran/security-banner-dismissed"
SECURITY_WARNING_FILE = "/var/lib/sovran/security-warning"
# ── Tech Support constants ──────────────────────────────────────── # ── Tech Support constants ────────────────────────────────────────
@@ -2927,112 +2926,190 @@ async def api_domains_check(req: DomainCheckRequest):
return {"domains": list(check_results)} return {"domains": list(check_results)}
# ── Legacy security check ───────────────────────────────────────── # ── Security endpoints ────────────────────────────────────────────
@app.get("/api/security/status")
async def api_security_status():
"""Return the legacy security status and warning message, if present.
Reads /var/lib/sovran/security-status and /var/lib/sovran/security-warning. @app.get("/api/security/banner-status")
Returns {"status": "legacy", "warning": "<message>"} for legacy machines, async def api_security_banner_status():
or {"status": "ok", "warning": ""} when the files are absent. """Return whether the first-login security banner should be shown.
The banner is shown only when:
1. The machine has completed onboarding (ONBOARDING_FLAG exists).
2. The banner has not been dismissed yet (SECURITY_BANNER_DISMISSED_FLAG absent).
Legacy machines (no ONBOARDING_FLAG) will never see the banner.
""" """
onboarded = os.path.isfile(ONBOARDING_FLAG)
dismissed = os.path.isfile(SECURITY_BANNER_DISMISSED_FLAG)
return {"show": onboarded and not dismissed}
@app.post("/api/security/banner-dismiss")
async def api_security_banner_dismiss():
"""Mark the first-login security banner as dismissed."""
try: try:
with open(SECURITY_STATUS_FILE, "r") as f: os.makedirs(os.path.dirname(SECURITY_BANNER_DISMISSED_FLAG), exist_ok=True)
status = f.read().strip() open(SECURITY_BANNER_DISMISSED_FLAG, "w").close()
except FileNotFoundError: except OSError as exc:
status = "ok" raise HTTPException(status_code=500, detail=f"Could not write dismiss flag: {exc}")
return {"ok": True}
warning = ""
if status == "legacy":
try:
with open(SECURITY_WARNING_FILE, "r") as f:
warning = f.read().strip()
except FileNotFoundError:
warning = (
"This machine was manufactured before the factory-seal process. "
"The default system password may be known to the factory. "
"Please change your system and application passwords immediately."
)
elif status == "unsealed":
try:
with open(SECURITY_WARNING_FILE, "r") as f:
warning = f.read().strip()
except FileNotFoundError:
warning = (
"This machine was set up without the factory seal process. "
"Factory test data — including SSH keys, database contents, and wallet information — "
"may still be present on this system."
)
return {"status": status, "warning": warning}
def _is_free_password_default() -> bool: @app.post("/api/security/reset")
"""Check /etc/shadow directly to see if 'free' still has a factory default password. async def api_security_reset():
"""Perform a full security reset.
Hashes each known factory default against the current shadow hash so that Wipes secrets, LND wallet, SSH keys, drops databases, removes app configs,
password changes made via GNOME, passwd, or any method other than the Hub clears Vaultwarden data, removes the onboarding-complete flag so onboarding
are detected correctly. re-runs, and reboots the system.
""" """
import subprocess wipe_paths = [
import re as _re "/var/lib/secrets",
"/var/lib/sovran",
"/root/.ssh",
"/home/free/.ssh",
"/var/lib/lnd",
"/var/lib/vaultwarden",
]
FACTORY_DEFAULTS = ["free", "gosovransystems"] errors: list[str] = []
# Map shadow algorithm IDs to openssl passwd flags (SHA-512 and SHA-256 only,
# matching the shell-script counterpart in factory-seal.nix) # Wipe filesystem paths
ALGO_FLAGS = {"6": "-6", "5": "-5"} for path in wipe_paths:
if os.path.exists(path):
try:
import shutil as _shutil
_shutil.rmtree(path, ignore_errors=True)
except Exception as exc:
errors.append(f"rm {path}: {exc}")
# Drop PostgreSQL databases (matrix-synapse, nextcloud, etc.)
try: try:
with open("/etc/shadow", "r") as f: result = subprocess.run(
for line in f: ["sudo", "-u", "postgres", "psql", "-c",
parts = line.strip().split(":") "SELECT datname FROM pg_database WHERE datistemplate = false AND datname NOT IN ('postgres')"],
if parts[0] == "free" and len(parts) > 1: capture_output=True, text=True, timeout=30,
current_hash = parts[1] )
if not current_hash or current_hash in ("!", "*", "!!"): if result.returncode == 0:
return True # locked/no password — treat as default for line in result.stdout.splitlines():
# Parse hash: $id$[rounds=N$]salt$hash dbname = line.strip()
hash_fields = current_hash.split("$") if dbname and not dbname.startswith("-") and dbname != "datname":
# hash_fields: ["", id, salt_or_rounds, ...] subprocess.run(
if len(hash_fields) < 4: ["sudo", "-u", "postgres", "dropdb", "--if-exists", dbname],
return True # unrecognized format — assume default for safety capture_output=True, text=True, timeout=30,
algo_id = hash_fields[1] )
salt_field = hash_fields[2] except Exception as exc:
if algo_id not in ALGO_FLAGS: errors.append(f"postgres wipe: {exc}")
return True # unrecognized algorithm — assume default for safety
if salt_field.startswith("rounds="): # Drop MariaDB databases
return True # can't extract real salt simply — assume default for safety try:
# Validate salt contains only safe characters (alphanumeric, '.', '/', '-', '_') result = subprocess.run(
# to guard against unexpected shadow file content before passing to subprocess ["mysql", "-u", "root", "-e",
if not _re.fullmatch(r"[A-Za-z0-9./\-_]+", salt_field): "SHOW DATABASES"],
return True # unexpected salt format — assume default for safety capture_output=True, text=True, timeout=30,
openssl_flag = ALGO_FLAGS[algo_id] )
for default_pw in FACTORY_DEFAULTS: if result.returncode == 0:
try: skip = {"Database", "information_schema", "performance_schema", "mysql", "sys"}
result = subprocess.run( for line in result.stdout.splitlines():
["openssl", "passwd", openssl_flag, "-salt", salt_field, default_pw], dbname = line.strip()
capture_output=True, if dbname and dbname not in skip:
text=True, subprocess.run(
timeout=5, ["mysql", "-u", "root", "-e", f"DROP DATABASE IF EXISTS `{dbname}`"],
) capture_output=True, text=True, timeout=30,
if result.returncode == 0 and result.stdout.strip() == current_hash: )
return True except Exception as exc:
except Exception: errors.append(f"mariadb wipe: {exc}")
return True # if openssl fails, assume default for safety
return False # Reboot
except (FileNotFoundError, PermissionError): try:
subprocess.Popen(["systemctl", "reboot"])
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Reboot failed: {exc}")
return {"ok": True, "errors": errors}
@app.post("/api/security/verify-integrity")
async def api_security_verify_integrity():
"""Verify system integrity using NixOS reproducibility features.
Reads /etc/nixos/flake.lock for the current commit hash, runs
`nix store verify --all` to check binary integrity, and compares the
current running system to what the flake says it should be.
"""
import json as _json
# ── 1. Read flake commit ──────────────────────────────────────
flake_commit = ""
repo_url = ""
try:
with open("/etc/nixos/flake.lock", "r") as f:
lock_data = _json.load(f)
# The root node's inputs → find the first locked input with a rev
nodes = lock_data.get("nodes", {})
root_inputs = nodes.get("root", {}).get("inputs", {})
for input_name in root_inputs.values():
node_name = input_name if isinstance(input_name, str) else (input_name[0] if input_name else "")
node = nodes.get(node_name, {})
locked = node.get("locked", {})
if locked.get("rev"):
flake_commit = locked["rev"]
# Build repo URL from locked info if available
owner = locked.get("owner", "")
repo = locked.get("repo", "")
if owner and repo:
repo_url = f"https://github.com/{owner}/{repo}/commit/{flake_commit}"
break
except Exception:
pass pass
return True # if /etc/shadow is unreadable, assume default for safety
# ── 2. Verify Nix store ───────────────────────────────────────
store_verified = False
store_errors: list[str] = []
try:
result = subprocess.run(
["nix", "store", "verify", "--all", "--no-trust"],
capture_output=True, text=True, timeout=300,
)
combined = (result.stdout + result.stderr).strip()
if result.returncode == 0:
store_verified = True
else:
store_errors = [line for line in combined.splitlines() if line.strip()]
except subprocess.TimeoutExpired:
store_errors = ["Verification timed out after 5 minutes."]
except Exception as exc:
store_errors = [str(exc)]
@app.get("/api/security/password-is-default") # ── 3. Compare running system to flake build ──────────────────
async def api_password_is_default(): system_matches = False
"""Check if the free account password is still the factory default. current_system_path = ""
expected_system_path = ""
try:
current_system_path = os.path.realpath("/run/current-system")
result = subprocess.run(
["nixos-rebuild", "build", "--flake", "/etc/nixos", "--no-build-output"],
capture_output=True, text=True, timeout=600,
)
if result.returncode == 0:
# nixos-rebuild build creates ./result symlink in cwd
result_path = os.path.realpath("result")
expected_system_path = result_path
system_matches = (current_system_path == expected_system_path)
except subprocess.TimeoutExpired:
expected_system_path = "Build timed out"
except Exception as exc:
expected_system_path = str(exc)
Uses /etc/shadow as the authoritative source so that password changes made return {
via GNOME Settings, the passwd command, or any other method are detected "flake_commit": flake_commit,
correctly — not just changes made through the Hub or change-free-password. "repo_url": repo_url,
""" "store_verified": store_verified,
return {"is_default": _is_free_password_default()} "store_errors": store_errors,
"system_matches": system_matches,
"current_system_path": current_system_path,
"expected_system_path": expected_system_path,
}
# ── System password change ──────────────────────────────────────── # ── System password change ────────────────────────────────────────
@@ -3051,8 +3128,6 @@ async def api_change_password(req: ChangePasswordRequest):
Updates /etc/shadow via chpasswd and writes the new password to Updates /etc/shadow via chpasswd and writes the new password to
/var/lib/secrets/free-password so the Hub credentials view stays in sync. /var/lib/secrets/free-password so the Hub credentials view stays in sync.
Also clears the legacy security-status and security-warning files so the
security banner disappears after a successful change.
""" """
if not req.new_password: if not req.new_password:
raise HTTPException(status_code=400, detail="New password must not be empty.") raise HTTPException(status_code=400, detail="New password must not be empty.")
@@ -3098,22 +3173,6 @@ async def api_change_password(req: ChangePasswordRequest):
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}") raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}")
# Clear legacy security status so the warning banner is removed — but only
# for "legacy" machines (pre-seal era). For "unsealed" machines, changing
# passwords is not enough; the factory residue (SSH keys, wallet data,
# databases) remains until a proper re-seal or re-install is performed.
try:
with open(SECURITY_STATUS_FILE, "r") as f:
current_status = f.read().strip()
if current_status == "legacy":
os.remove(SECURITY_STATUS_FILE)
try:
os.remove(SECURITY_WARNING_FILE)
except FileNotFoundError:
pass
except (FileNotFoundError, OSError):
pass
return {"ok": True} return {"ok": True}

View File

@@ -1,3 +1,270 @@
/* ── Security Modal sections ──────────────────────────────────────── */
.security-section {
padding: 0 0 8px 0;
}
.security-section-title {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 10px 0;
}
.security-section-desc {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 10px;
}
.security-divider {
border: none;
border-top: 1px solid var(--border-color);
margin: 18px 0;
}
/* ── Security Reset warning box ──────────────────────────────────── */
.security-warning-box {
background-color: rgba(180, 40, 40, 0.10);
border-left: 3px solid #c94040;
border-radius: 6px;
padding: 12px 14px;
margin-bottom: 14px;
}
.security-warning-text {
font-size: 0.85rem;
color: var(--text-primary);
margin: 0 0 8px 0;
line-height: 1.5;
}
.security-warning-list {
margin: 6px 0 6px 20px;
padding: 0;
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.7;
}
.security-erase-group {
margin-bottom: 14px;
}
.security-erase-label {
display: block;
font-size: 0.85rem;
color: var(--text-primary);
margin-bottom: 6px;
}
.security-erase-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--input-bg, var(--card-color));
color: var(--text-primary);
font-size: 0.9rem;
box-sizing: border-box;
}
.security-reset-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-danger {
background-color: #c94040;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-danger:hover:not(:disabled) {
background-color: #a83030;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.security-status-msg {
font-size: 0.83rem;
margin-top: 10px;
min-height: 1.2em;
}
.security-status-info { color: var(--text-secondary); }
.security-status-ok { color: #2ec27e; }
.security-status-error { color: #e05252; }
/* ── Verify System Integrity ─────────────────────────────────────── */
.security-verify-list {
margin: 8px 0 10px 20px;
padding: 0;
font-size: 0.84rem;
color: var(--text-secondary);
line-height: 1.7;
}
.security-verify-loading {
font-size: 0.85rem;
color: var(--text-secondary);
}
.security-verify-result-card {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.security-verify-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.security-verify-label {
font-size: 0.82rem;
color: var(--text-secondary);
min-width: 130px;
font-weight: 600;
}
.security-verify-value {
font-size: 0.82rem;
color: var(--text-primary);
}
.security-verify-mono {
font-family: monospace;
font-size: 0.78rem;
word-break: break-all;
}
.security-verify-link {
font-size: 0.78rem;
color: var(--accent-color, #2ec27e);
text-decoration: none;
}
.security-verify-link:hover { text-decoration: underline; }
.security-verify-badge {
font-size: 0.82rem;
font-weight: 700;
padding: 2px 10px;
border-radius: 12px;
}
.security-verify-pass {
background-color: rgba(46, 194, 126, 0.15);
color: #2ec27e;
}
.security-verify-fail {
background-color: rgba(224, 82, 82, 0.15);
color: #e05252;
}
.security-verify-errors {
font-size: 0.8rem;
color: var(--text-secondary);
}
.security-verify-pre {
white-space: pre-wrap;
word-break: break-all;
font-size: 0.75rem;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px;
margin-top: 6px;
max-height: 150px;
overflow-y: auto;
}
.security-verify-path-row {
display: flex;
gap: 8px;
font-size: 0.78rem;
align-items: flex-start;
flex-wrap: wrap;
}
.security-verify-path-label {
font-weight: 600;
color: var(--text-secondary);
min-width: 70px;
}
/* ── First-login security banner ─────────────────────────────────── */
.security-first-login-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
background-color: rgba(46, 100, 194, 0.12);
border-bottom: 2px solid rgba(46, 100, 194, 0.35);
color: var(--text-primary);
}
.security-banner-content {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1;
min-width: 0;
}
.security-banner-icon {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 1px;
}
.security-banner-text {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-primary);
}
.security-banner-dismiss {
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
padding: 2px 7px;
flex-shrink: 0;
line-height: 1.4;
transition: background-color 0.15s;
}
.security-banner-dismiss:hover {
background-color: rgba(0,0,0,0.08);
}
/* ── Legacy security inline warning banner ───────────────────────── */ /* ── Legacy security inline warning banner ───────────────────────── */
.security-inline-banner { .security-inline-banner {

View File

@@ -70,6 +70,47 @@ async function doUpgradeToServer() {
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer); if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer);
// ── First-login security banner ───────────────────────────────────
function showSecurityBanner() {
var existing = document.getElementById("security-first-login-banner");
if (existing) return;
var banner = document.createElement("div");
banner.id = "security-first-login-banner";
banner.className = "security-first-login-banner";
banner.innerHTML =
'<div class="security-banner-content">' +
'<span class="security-banner-icon">\uD83D\uDEE1</span>' +
'<span class="security-banner-text">' +
'<strong>Did someone else set up this machine?</strong> ' +
'If this computer was pre-configured by another person, go to ' +
'<strong>Menu \u2192 Security</strong> to reset all passwords and keys. ' +
'This ensures only you have access.' +
'</span>' +
'</div>' +
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
var mainContent = document.querySelector(".main-content");
if (mainContent) {
mainContent.insertAdjacentElement("beforebegin", banner);
} else {
document.body.insertAdjacentElement("afterbegin", banner);
}
var dismissBtn = document.getElementById("security-banner-dismiss-btn");
if (dismissBtn) {
dismissBtn.addEventListener("click", async function() {
banner.remove();
try {
await apiFetch("/api/security/banner-dismiss", { method: "POST" });
} catch (_) {
// Non-fatal
}
});
}
}
// ── Init ────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────
async function init() { async function init() {
@@ -84,8 +125,16 @@ async function init() {
// If we can't reach the endpoint, continue to normal dashboard // If we can't reach the endpoint, continue to normal dashboard
} }
// Check for legacy machine security warning // Show first-login security banner only for machines that went through onboarding
await checkLegacySecurity(); // (legacy machines without the onboarding flag will never see this)
try {
var bannerData = await apiFetch("/api/security/banner-status");
if (bannerData && bannerData.show) {
showSecurityBanner();
}
} catch (_) {
// Non-fatal — silently ignore
}
try { try {
var cfg = await apiFetch("/api/config"); var cfg = await apiFetch("/api/config");

View File

@@ -599,29 +599,9 @@ function renderAutolaunchToggle(enabled) {
var section = document.createElement("div"); var section = document.createElement("div");
section.className = "category-section autolaunch-section"; section.className = "category-section autolaunch-section";
var securityBanner = "";
if (_securityIsLegacy) {
var msg = _securityWarningMessage || "Your system may have factory default passwords. Please change your passwords to secure your system.";
var linkText, linkAction;
if (_securityStatus === "unsealed") {
linkText = "Contact Support";
linkAction = "openSupportModal(); return false;";
} else {
linkText = "Change Passwords";
linkAction = "openServiceDetailModal('root-password-setup.service', 'System Passwords', 'passwords'); return false;";
}
securityBanner =
'<div class="security-inline-banner">' +
'<span class="security-inline-icon">⚠</span>' +
'<span class="security-inline-text">' + msg + '</span>' +
'<a class="security-inline-link" href="#" onclick="' + linkAction + '">' + linkText + '</a>' +
'</div>';
}
section.innerHTML = section.innerHTML =
'<div class="section-header">Preferences</div>' + '<div class="section-header">Preferences</div>' +
'<hr class="section-divider" />' + '<hr class="section-divider" />' +
securityBanner +
'<div class="feature-card">' + '<div class="feature-card">' +
'<div class="feature-card-top">' + '<div class="feature-card-top">' +
'<div class="feature-card-info">' + '<div class="feature-card-info">' +

View File

@@ -1,16 +1,201 @@
"use strict"; "use strict";
// ── Legacy security warning ─────────────────────────────────────── // ── Security Modal ────────────────────────────────────────────────
async function checkLegacySecurity() { function openSecurityModal() {
try { if ($supportModal) $supportModal.classList.add("open");
var data = await apiFetch("/api/security/status"); var title = document.getElementById("support-modal-title");
if (data && (data.status === "legacy" || data.status === "unsealed")) { if (title) title.textContent = "\uD83D\uDEE1 Security";
_securityIsLegacy = true;
_securityStatus = data.status; if ($supportBody) {
_securityWarningMessage = data.warning || "This machine may have a security issue. Please review your system security."; $supportBody.innerHTML =
// ── Section A: Security Reset ──────────────────────────────
'<div class="security-section">' +
'<h3 class="security-section-title">Security Reset</h3>' +
'<p class="security-section-desc">' +
'Run this if you are using this physical computer for the first time <strong>AND</strong> ' +
'it was not set up by you. This will complete the security setup by resetting all passwords ' +
'and your Bitcoin Lightning Node\u2019s private keys.' +
'</p>' +
'<p class="security-section-desc">' +
'You can also run this if you wish to reset all your passwords and your Bitcoin Lightning ' +
'Node\u2019s private keys. If you have not transferred the Bitcoin out of this node and did ' +
'not back up the private keys, <strong>you will lose your Bitcoin.</strong>' +
'</p>' +
'<button class="btn btn-primary" id="security-reset-open-btn">Proceed with Security Reset</button>' +
'<div id="security-reset-confirm" style="display:none;margin-top:16px;">' +
'<div class="security-warning-box">' +
'<p class="security-warning-text">' +
'<strong>\u26A0\uFE0F This will permanently delete:</strong>' +
'</p>' +
'<ul class="security-warning-list">' +
'<li>All generated passwords and SSH keys</li>' +
'<li>LND wallet data (seed words, channels, macaroons)</li>' +
'<li>Application databases</li>' +
'<li>Vaultwarden data</li>' +
'</ul>' +
'<p class="security-warning-text">You will go through onboarding again. <strong>This cannot be undone.</strong></p>' +
'</div>' +
'<div class="security-erase-group">' +
'<label class="security-erase-label" for="security-erase-input">Type <strong>ERASE</strong> to confirm:</label>' +
'<input class="security-erase-input" type="text" id="security-erase-input" autocomplete="off" placeholder="ERASE" />' +
'</div>' +
'<div class="security-reset-actions">' +
'<button class="btn btn-close-modal" id="security-reset-cancel-btn">Cancel</button>' +
'<button class="btn btn-danger" id="security-reset-confirm-btn" disabled>Erase &amp; Reset</button>' +
'</div>' +
'<div id="security-reset-status" class="security-status-msg"></div>' +
'</div>' +
'</div>' +
'<hr class="security-divider" />' +
// ── Section B: Verify System Integrity ────────────────────
'<div class="security-section">' +
'<h3 class="security-section-title">Verify System Integrity</h3>' +
'<p class="security-section-desc">' +
'Your Sovran_SystemsOS is built with NixOS \u2014 a system designed for complete transparency ' +
'and reproducibility. Every piece of software on this machine is built from publicly auditable ' +
'source code and verified using cryptographic hashes.' +
'</p>' +
'<p class="security-section-desc">This verification confirms three things:</p>' +
'<ol class="security-verify-list">' +
'<li>' +
'<strong>Source Code Match</strong> \u2014 The system configuration on this machine matches ' +
'the exact commit published in the public repository. No hidden changes were added.' +
'</li>' +
'<li>' +
'<strong>Binary Integrity</strong> \u2014 Every installed package in the system store is ' +
'verified against its expected cryptographic hash. If any binary, library, or config file ' +
'was tampered with, it will be detected.' +
'</li>' +
'<li>' +
'<strong>Running System Match</strong> \u2014 The currently running system matches what the ' +
'configuration says it should be. No unauthorized modifications are active.' +
'</li>' +
'</ol>' +
'<p class="security-section-desc">' +
'In short: if this verification passes, you can be confident that the software running on ' +
'your machine is exactly what is published \u2014 nothing more, nothing less.' +
'</p>' +
'<button class="btn btn-primary" id="security-verify-btn">Verify Now</button>' +
'<div id="security-verify-results" style="display:none;margin-top:16px;"></div>' +
'</div>';
// ── Wire Security Reset flow
var resetOpenBtn = document.getElementById("security-reset-open-btn");
var resetConfirmDiv = document.getElementById("security-reset-confirm");
var eraseInput = document.getElementById("security-erase-input");
var resetConfirmBtn = document.getElementById("security-reset-confirm-btn");
var resetCancelBtn = document.getElementById("security-reset-cancel-btn");
var resetStatus = document.getElementById("security-reset-status");
if (resetOpenBtn) {
resetOpenBtn.addEventListener("click", function() {
resetOpenBtn.style.display = "none";
if (resetConfirmDiv) resetConfirmDiv.style.display = "";
if (eraseInput) eraseInput.focus();
});
}
if (eraseInput && resetConfirmBtn) {
eraseInput.addEventListener("input", function() {
resetConfirmBtn.disabled = eraseInput.value.trim() !== "ERASE";
});
}
if (resetCancelBtn) {
resetCancelBtn.addEventListener("click", function() {
if (resetConfirmDiv) resetConfirmDiv.style.display = "none";
if (resetOpenBtn) resetOpenBtn.style.display = "";
if (eraseInput) eraseInput.value = "";
if (resetConfirmBtn) resetConfirmBtn.disabled = true;
if (resetStatus) { resetStatus.textContent = ""; resetStatus.className = "security-status-msg"; }
});
}
if (resetConfirmBtn) {
resetConfirmBtn.addEventListener("click", async function() {
if (!eraseInput || eraseInput.value.trim() !== "ERASE") return;
resetConfirmBtn.disabled = true;
resetConfirmBtn.textContent = "Erasing\u2026";
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 (resetStatus) { resetStatus.textContent = "\u2713 Reset complete. Rebooting\u2026"; resetStatus.className = "security-status-msg security-status-ok"; }
if ($rebootOverlay) $rebootOverlay.classList.add("open");
} catch (err) {
if (resetStatus) { resetStatus.textContent = "\u2717 Error: " + (err.message || "Reset failed."); resetStatus.className = "security-status-msg security-status-error"; }
resetConfirmBtn.disabled = false;
resetConfirmBtn.textContent = "Erase & Reset";
}
});
}
// ── Wire Verify System Integrity
var verifyBtn = document.getElementById("security-verify-btn");
var verifyResults = document.getElementById("security-verify-results");
if (verifyBtn && verifyResults) {
verifyBtn.addEventListener("click", async function() {
verifyBtn.disabled = true;
verifyBtn.textContent = "Verifying\u2026";
verifyResults.style.display = "";
verifyResults.innerHTML = '<p class="security-verify-loading">\u231B Running verification checks\u2026 This may take a few minutes.</p>';
try {
var data = await apiFetch("/api/security/verify-integrity", { method: "POST" });
var html = '<div class="security-verify-result-card">';
// Flake commit
html += '<div class="security-verify-row">';
html += '<span class="security-verify-label">Source Commit:</span>';
html += '<span class="security-verify-value security-verify-mono">' + escHtml(data.flake_commit || "unknown") + '</span>';
if (data.repo_url) {
html += '<a class="security-verify-link" href="' + escHtml(data.repo_url) + '" target="_blank" rel="noopener noreferrer">View on Gitea \u2197</a>';
}
html += '</div>';
// Store verification
var storeOk = data.store_verified === true;
html += '<div class="security-verify-row">';
html += '<span class="security-verify-label">Binary Integrity:</span>';
html += '<span class="security-verify-badge ' + (storeOk ? "security-verify-pass" : "security-verify-fail") + '">';
html += storeOk ? "\u2705 PASS" : "\u274C FAIL";
html += '</span>';
html += '</div>';
if (!storeOk && data.store_errors && data.store_errors.length > 0) {
html += '<details class="security-verify-errors"><summary>Show errors (' + data.store_errors.length + ')</summary>';
html += '<pre class="security-verify-pre">' + escHtml(data.store_errors.join("\n")) + '</pre>';
html += '</details>';
}
// System match
var sysOk = data.system_matches === true;
html += '<div class="security-verify-row">';
html += '<span class="security-verify-label">Running System Match:</span>';
html += '<span class="security-verify-badge ' + (sysOk ? "security-verify-pass" : "security-verify-fail") + '">';
html += sysOk ? "\u2705 PASS" : "\u274C FAIL";
html += '</span>';
html += '</div>';
if (!sysOk) {
html += '<div class="security-verify-path-row">';
html += '<span class="security-verify-path-label">Current:</span><code class="security-verify-mono">' + escHtml(data.current_system_path || "") + '</code>';
html += '</div>';
html += '<div class="security-verify-path-row">';
html += '<span class="security-verify-path-label">Expected:</span><code class="security-verify-mono">' + escHtml(data.expected_system_path || "") + '</code>';
html += '</div>';
}
html += '</div>';
verifyResults.innerHTML = html;
} catch (err) {
verifyResults.innerHTML = '<p class="security-status-msg security-status-error">\u274C Verification failed: ' + escHtml(err.message || "Unknown error") + '</p>';
}
verifyBtn.disabled = false;
verifyBtn.textContent = "Verify Now";
});
} }
} catch (_) {
// Non-fatal — silently ignore if the endpoint is unreachable
} }
} }

View File

@@ -612,15 +612,6 @@ function openSystemChangePasswordModal(unit, name, icon) {
resultEl.textContent = "✅ System password changed successfully."; resultEl.textContent = "✅ System password changed successfully.";
submitBtn.textContent = "Change Password"; submitBtn.textContent = "Change Password";
submitBtn.disabled = false; submitBtn.disabled = false;
// Hide the legacy security banner if it's visible — but only for
// "legacy" status machines. For "unsealed" machines, changing passwords
// is not enough; the factory residue remains until a proper re-seal or re-install.
if (typeof _securityIsLegacy !== "undefined" && _securityIsLegacy &&
(typeof _securityStatus === "undefined" || _securityStatus !== "unsealed")) {
_securityIsLegacy = false;
var banner = document.querySelector(".security-inline-banner");
if (banner) banner.remove();
}
} catch (err) { } catch (err) {
resultEl.className = "matrix-form-result error"; resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to change password."); resultEl.textContent = "❌ " + (err.message || "Failed to change password.");

View File

@@ -99,10 +99,5 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn"); const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn"); const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
// Legacy security warning state (populated by checkLegacySecurity in security.js)
var _securityIsLegacy = false;
var _securityStatus = "ok"; // "ok", "legacy", or "unsealed"
var _securityWarningMessage = "";
// System status banner // System status banner
// (removed — health is now shown per-tile via the composite health field) // (removed — health is now shown per-tile via the composite health field)

View File

@@ -89,6 +89,18 @@ function renderSidebarSupport(supportServices) {
backupBtn.addEventListener("click", function() { openBackupModal(); }); backupBtn.addEventListener("click", function() { openBackupModal(); });
$sidebarSupport.appendChild(backupBtn); $sidebarSupport.appendChild(backupBtn);
// ── Security button
var securityBtn = document.createElement("button");
securityBtn.className = "sidebar-support-btn";
securityBtn.innerHTML =
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
'<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Security</span>' +
'<span class="sidebar-support-hint">Reset &amp; verify system</span>' +
'</span>';
securityBtn.addEventListener("click", function() { openSecurityModal(); });
$sidebarSupport.appendChild(securityBtn);
// ── Upgrade button (Node role only) // ── Upgrade button (Node role only)
if (_currentRole === "node") { if (_currentRole === "node") {
var upgradeBtn = document.createElement("button"); var upgradeBtn = document.createElement("button");

View File

@@ -1,25 +1,22 @@
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard /* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
Drives the 6-step post-install setup flow. */ Drives the 5-step post-install setup flow. */
"use strict"; "use strict";
// ── Constants ───────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────
const TOTAL_STEPS = 6; const TOTAL_STEPS = 5;
// Steps to skip per role (steps 4 and 5 involve domain/port setup) // Steps to skip per role (steps 3 and 4 involve domain/port setup)
// Steps 2 (timezone/locale) and 3 (password) are NEVER skipped — all roles need them. // Step 2 (timezone/locale) is NEVER skipped — all roles need it.
const ROLE_SKIP_STEPS = { const ROLE_SKIP_STEPS = {
"desktop": [4, 5], "desktop": [3, 4],
"node": [4, 5], "node": [3, 4],
}; };
// ── Role state (loaded at init) ─────────────────────────────────── // ── Role state (loaded at init) ───────────────────────────────────
var _onboardingRole = "server_plus_desktop"; var _onboardingRole = "server_plus_desktop";
// Password default state (loaded at step 2)
var _passwordIsDefault = true;
// Domains that may need configuration, with service unit mapping for enabled check // Domains that may need configuration, with service unit mapping for enabled check
const DOMAIN_DEFS = [ const DOMAIN_DEFS = [
{ name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true }, { name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true },
@@ -96,8 +93,7 @@ function showStep(step) {
if (step === 2) loadStep2(); if (step === 2) loadStep2();
if (step === 3) loadStep3(); if (step === 3) loadStep3();
if (step === 4) loadStep4(); if (step === 4) loadStep4();
if (step === 5) loadStep5(); // Step 5 (Complete) is static — no lazy-load needed
// Step 6 (Complete) is static — no lazy-load needed
} }
// Return the next step number, skipping over role-excluded steps // Return the next step number, skipping over role-excluded steps
@@ -273,135 +269,12 @@ async function saveStep2() {
return true; return true;
} }
// ── Step 3: Create Your Password ───────────────────────────────── // ── Step 3: Domain Configuration ─────────────────────────────────
async function loadStep3() { async function loadStep3() {
var body = document.getElementById("step-3-body"); var body = document.getElementById("step-3-body");
if (!body) return; if (!body) return;
var nextBtn = document.getElementById("step-3-next");
try {
var result = await apiFetch("/api/security/password-is-default");
_passwordIsDefault = result.is_default !== false;
} catch (_) {
_passwordIsDefault = true;
}
if (_passwordIsDefault) {
// Factory-sealed scenario: password must be set before continuing
if (nextBtn) nextBtn.textContent = "Set Password & Continue \u2192";
body.innerHTML =
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
'</div>' +
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
'</div>' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
'</div>' +
'</div>' +
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
});
} else {
// DIY install scenario: password already set by installer
if (nextBtn) nextBtn.textContent = "Continue \u2192";
body.innerHTML =
'<div class="onboarding-password-success">✅ Your password was already set during installation.</div>' +
'<details class="onboarding-password-optional">' +
'<summary>Change it anyway</summary>' +
'<div style="margin-top:14px;">' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
'</div>' +
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
'</div>' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
'</div>' +
'</div>' +
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>' +
'</div>' +
'</details>';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
});
}
}
async function saveStep3() {
var newPw = document.getElementById("pw-new");
var confirmPw = document.getElementById("pw-confirm");
// If no fields visible or both empty and password already set → skip
if (!newPw || !newPw.value.trim()) {
if (!_passwordIsDefault) return true; // already set, no change requested
setStatus("step-3-status", "⚠ Please enter a password.", "error");
return false;
}
var pw = newPw.value;
var cpw = confirmPw ? confirmPw.value : "";
if (pw.length < 8) {
setStatus("step-3-status", "⚠ Password must be at least 8 characters.", "error");
return false;
}
if (pw !== cpw) {
setStatus("step-3-status", "⚠ Passwords do not match.", "error");
return false;
}
setStatus("step-3-status", "Saving password…", "info");
try {
await apiFetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
});
} catch (err) {
setStatus("step-3-status", "⚠ " + err.message, "error");
return false;
}
setStatus("step-3-status", "✓ Password saved", "ok");
_passwordIsDefault = false;
return true;
}
// ── Step 4: Domain Configuration ─────────────────────────────────
async function loadStep4() {
var body = document.getElementById("step-4-body");
if (!body) return;
try { try {
// Fetch services, domains, and network info in parallel // Fetch services, domains, and network info in parallel
var results = await Promise.all([ var results = await Promise.all([
@@ -542,8 +415,8 @@ async function loadStep4() {
}); });
} }
async function saveStep4() { async function saveStep3() {
setStatus("step-4-status", "Saving domains…", "info"); setStatus("step-3-status", "Saving domains…", "info");
var errors = []; var errors = [];
// Save each domain input // Save each domain input
@@ -583,18 +456,18 @@ async function saveStep4() {
} }
if (errors.length > 0) { if (errors.length > 0) {
setStatus("step-4-status", "⚠ Some errors: " + errors.join("; "), "error"); setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
return false; return false;
} }
setStatus("step-4-status", "✓ Saved", "ok"); setStatus("step-3-status", "✓ Saved", "ok");
return true; return true;
} }
// ── Step 5: Port Forwarding ─────────────────────────────────────── // ── Step 4: Port Forwarding ───────────────────────────────────────
async function loadStep5() { async function loadStep4() {
var body = document.getElementById("step-5-body"); var body = document.getElementById("step-4-body");
if (!body) return; if (!body) return;
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>'; body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
@@ -675,10 +548,10 @@ async function loadStep5() {
body.innerHTML = html; body.innerHTML = html;
} }
// ── Step 6: Complete ────────────────────────────────────────────── // ── Step 5: Complete ──────────────────────────────────────────────
async function completeOnboarding() { async function completeOnboarding() {
var btn = document.getElementById("step-6-finish"); var btn = document.getElementById("step-5-finish");
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; } if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
try { try {
@@ -709,36 +582,24 @@ function wireNavButtons() {
if (ok) showStep(nextStep(2)); if (ok) showStep(nextStep(2));
}); });
// Step 3 → 4 (save password first) // Step 3 → 4 (save domains first)
var s3next = document.getElementById("step-3-next"); var s3next = document.getElementById("step-3-next");
if (s3next) s3next.addEventListener("click", async function() { if (s3next) s3next.addEventListener("click", async function() {
s3next.disabled = true; s3next.disabled = true;
var origText = s3next.textContent;
s3next.textContent = "Saving…"; s3next.textContent = "Saving…";
var ok = await saveStep3(); await saveStep3();
s3next.disabled = false; s3next.disabled = false;
s3next.textContent = origText; s3next.textContent = "Save & Continue →";
if (ok) showStep(nextStep(3)); showStep(nextStep(3));
}); });
// Step 4 → 5 (save domains first) // Step 4 → 5 (port forwarding — no save needed)
var s4next = document.getElementById("step-4-next"); var s4next = document.getElementById("step-4-next");
if (s4next) s4next.addEventListener("click", async function() { if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
s4next.disabled = true;
s4next.textContent = "Saving…";
await saveStep4();
s4next.disabled = false;
s4next.textContent = "Save & Continue →";
showStep(nextStep(4));
});
// Step 5 → 6 (port forwarding — no save needed) // Step 5: finish
var s5next = document.getElementById("step-5-next"); var s5finish = document.getElementById("step-5-finish");
if (s5next) s5next.addEventListener("click", function() { showStep(nextStep(5)); }); if (s5finish) s5finish.addEventListener("click", completeOnboarding);
// Step 6: finish
var s6finish = document.getElementById("step-6-finish");
if (s6finish) s6finish.addEventListener("click", completeOnboarding);
// Back buttons // Back buttons
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {

View File

@@ -36,8 +36,6 @@
<span class="onboarding-step-dot" data-step="4">4</span> <span class="onboarding-step-dot" data-step="4">4</span>
<span class="onboarding-step-connector"></span> <span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="5">5</span> <span class="onboarding-step-dot" data-step="5">5</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="6">6</span>
</div> </div>
<!-- Step panels --> <!-- Step panels -->
@@ -96,29 +94,8 @@
</div> </div>
</div> </div>
<!-- ── Step 3: Create Your Password ── --> <!-- ── Step 3: Domain Configuration ── -->
<div class="onboarding-panel" id="step-3" style="display:none"> <div class="onboarding-panel" id="step-3" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🔒</span>
<h2 class="onboarding-step-title">Create Your Password</h2>
<p class="onboarding-step-desc">
Choose a strong password for your <strong>'free'</strong> user account.
</p>
</div>
<div class="onboarding-card" id="step-3-body">
<p class="onboarding-loading">Checking password status…</p>
</div>
<div id="step-3-status" class="onboarding-save-status"></div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
Set Password &amp; Continue →
</button>
</div>
</div>
<!-- ── Step 4: Domain Configuration ── -->
<div class="onboarding-panel" id="step-4" style="display:none">
<div class="onboarding-step-header"> <div class="onboarding-step-header">
<span class="onboarding-step-icon">🌐</span> <span class="onboarding-step-icon">🌐</span>
<h2 class="onboarding-step-title">Domain Configuration</h2> <h2 class="onboarding-step-title">Domain Configuration</h2>
@@ -129,20 +106,20 @@
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below. Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
</p> </p>
</div> </div>
<div class="onboarding-card" id="step-4-body"> <div class="onboarding-card" id="step-3-body">
<p class="onboarding-loading">Loading service information…</p> <p class="onboarding-loading">Loading service information…</p>
</div> </div>
<div id="step-4-status" class="onboarding-save-status"></div> <div id="step-3-status" class="onboarding-save-status"></div>
<div class="onboarding-footer"> <div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button> <button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-4-next"> <button class="btn btn-primary onboarding-btn-next" id="step-3-next">
Save &amp; Continue → Save &amp; Continue →
</button> </button>
</div> </div>
</div> </div>
<!-- ── Step 5: Port Forwarding ── --> <!-- ── Step 4: Port Forwarding ── -->
<div class="onboarding-panel" id="step-5" style="display:none"> <div class="onboarding-panel" id="step-4" style="display:none">
<div class="onboarding-step-header"> <div class="onboarding-step-header">
<span class="onboarding-step-icon">🔌</span> <span class="onboarding-step-icon">🔌</span>
<h2 class="onboarding-step-title">Port Forwarding Check</h2> <h2 class="onboarding-step-title">Port Forwarding Check</h2>
@@ -151,19 +128,19 @@
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong> <strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
</p> </p>
</div> </div>
<div class="onboarding-card" id="step-5-body"> <div class="onboarding-card" id="step-4-body">
<p class="onboarding-loading">Checking ports…</p> <p class="onboarding-loading">Checking ports…</p>
</div> </div>
<div class="onboarding-footer"> <div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button> <button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-5-next"> <button class="btn btn-primary onboarding-btn-next" id="step-4-next">
Continue → Continue →
</button> </button>
</div> </div>
</div> </div>
<!-- ── Step 6: Complete ── --> <!-- ── Step 5: Complete ── -->
<div class="onboarding-panel" id="step-6" style="display:none"> <div class="onboarding-panel" id="step-5" style="display:none">
<div class="onboarding-hero"> <div class="onboarding-hero">
<div class="onboarding-logo"></div> <div class="onboarding-logo"></div>
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1> <h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
@@ -176,14 +153,13 @@
</p> </p>
<ul class="onboarding-checklist" id="onboarding-checklist"> <ul class="onboarding-checklist" id="onboarding-checklist">
<li>✅ Timezone &amp; locale configured</li> <li>✅ Timezone &amp; locale configured</li>
<li>✅ Password configured</li>
<li>✅ Domain configuration saved</li> <li>✅ Domain configuration saved</li>
<li>✅ Port forwarding reviewed</li> <li>✅ Port forwarding reviewed</li>
</ul> </ul>
</div> </div>
<div class="onboarding-footer"> <div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="5">← Back</button> <button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
<button class="btn btn-primary" id="step-6-finish"> <button class="btn btn-primary" id="step-5-finish">
Go to Dashboard → Go to Dashboard →
</button> </button>
</div> </div>

View File

@@ -1,292 +0,0 @@
{ config, pkgs, lib, ... }:
let
sovran-factory-seal = pkgs.writeShellScriptBin "sovran-factory-seal" ''
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Error: must be run as root." >&2
exit 1
fi
echo ""
echo ""
echo " SOVRAN FACTORY SEAL WARNING "
echo ""
echo " This command will PERMANENTLY DELETE: "
echo " All generated passwords and secrets "
echo " LND wallet data (seed words, channels, macaroons) "
echo " SSH factory login key "
echo " Application databases (Matrix, Nextcloud, WordPress) "
echo " Vaultwarden database "
echo " "
echo " After sealing, all credentials will be regenerated fresh "
echo " when the customer boots the device for the first time. "
echo " "
echo " DO NOT run this on a customer's live system. "
echo ""
echo ""
echo -n "Type SEAL to confirm: "
read -r CONFIRM
if [ "$CONFIRM" != "SEAL" ]; then
echo "Aborted." >&2
exit 1
fi
echo ""
echo "Sealing system..."
# 1. Delete all generated secrets
echo " Wiping secrets..."
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
rm -rf /var/lib/matrix-synapse/registration-secret
rm -rf /var/lib/matrix-synapse/db-password
rm -rf /var/lib/gnome-remote-desktop/rdp-password
rm -rf /var/lib/gnome-remote-desktop/rdp-username
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
rm -rf /var/lib/livekit/livekit_keyFile
rm -rf /etc/nix-bitcoin-secrets/*
# 2. Wipe LND wallet (seed words, wallet DB, macaroons)
echo " Wiping LND wallet data..."
rm -rf /var/lib/lnd/*
# 3. Wipe SSH factory key so it regenerates with new passphrase
echo " Removing SSH factory key..."
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
if [ -f /root/.ssh/authorized_keys ]; then
sed -i '/factory_login/d' /root/.ssh/authorized_keys
fi
# 4. Drop application databases
echo " Dropping application databases..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
# 5. Remove application config files (so init services re-run)
echo " Removing application config files..."
rm -rf /var/lib/www/wordpress/wp-config.php
rm -rf /var/lib/www/nextcloud/config/config.php
# 6. Wipe Vaultwarden database
echo " Wiping Vaultwarden data..."
rm -rf /var/lib/bitwarden_rs/*
rm -rf /var/lib/vaultwarden/*
# 7. Set sealed flag and remove onboarded flag
echo " Setting sealed flag..."
touch /var/lib/sovran-factory-sealed
rm -f /var/lib/sovran-customer-onboarded
echo ""
echo "System sealed. Power off now or the system will shut down in 10 seconds."
sleep 10
poweroff
'';
in
{
environment.systemPackages = [ sovran-factory-seal ];
# ── Auto-seal on first customer boot ───────────────────────────────
systemd.services.sovran-auto-seal = {
description = "Auto-seal Sovran system on first customer boot";
wantedBy = [ "multi-user.target" ];
before = [ "sovran-hub.service" "sovran-legacy-security-check.service" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils pkgs.e2fsprogs pkgs.openssl pkgs.postgresql pkgs.mariadb pkgs.shadow ];
script = ''
# Idempotency check
if [ -f /var/lib/sovran-factory-sealed ]; then
echo "sovran-auto-seal: already sealed, nothing to do."
exit 0
fi
echo "sovran-auto-seal: seal flag missing checking system state..."
# Safety guard 1: customer has already onboarded
if [ -f /var/lib/sovran-customer-onboarded ]; then
echo "sovran-auto-seal: /var/lib/sovran-customer-onboarded exists live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
# Safety guard 2: onboarding was completed
if [ -f /var/lib/sovran/onboarding-complete ]; then
echo "sovran-auto-seal: /var/lib/sovran/onboarding-complete exists live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
# Safety guard 3: password has been changed from factory defaults
if [ -f /etc/shadow ]; then
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
STILL_DEFAULT=false
# If the salt field starts with "rounds=", we cannot extract the real salt
# with a simple cut treat as still-default for safety
if printf '%s' "$SALT" | grep -q '^rounds='; then
STILL_DEFAULT=true
else
for DEFAULT_PW in "free" "gosovransystems"; do
case "$ALGO_ID" in
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
*)
# Unknown hash algorithm treat as still-default for safety
STILL_DEFAULT=true
break
;;
esac
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
STILL_DEFAULT=true
break
fi
done
fi
if [ "$STILL_DEFAULT" = "false" ]; then
echo "sovran-auto-seal: password has been changed from factory defaults live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
fi
fi
# All safety guards passed: this is a fresh/unsealed system
echo "sovran-auto-seal: fresh system confirmed performing auto-seal..."
# 1. Wipe generated secrets
echo "sovran-auto-seal: wiping secrets..."
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
rm -rf /var/lib/matrix-synapse/registration-secret
rm -rf /var/lib/matrix-synapse/db-password
rm -rf /var/lib/gnome-remote-desktop/rdp-password
rm -rf /var/lib/gnome-remote-desktop/rdp-username
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
rm -rf /var/lib/livekit/livekit_keyFile
rm -rf /etc/nix-bitcoin-secrets/*
# 2. Wipe LND wallet data
echo "sovran-auto-seal: wiping LND wallet data..."
rm -rf /var/lib/lnd/*
# 3. Remove SSH factory key
echo "sovran-auto-seal: removing SSH factory key..."
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
if [ -f /root/.ssh/authorized_keys ]; then
sed -i '/factory_login/d' /root/.ssh/authorized_keys
fi
# 4. Drop application databases
echo "sovran-auto-seal: dropping application databases..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
# 5. Remove application config files
echo "sovran-auto-seal: removing application config files..."
rm -rf /var/lib/www/wordpress/wp-config.php
rm -rf /var/lib/www/nextcloud/config/config.php
# 6. Wipe Vaultwarden data
echo "sovran-auto-seal: wiping Vaultwarden data..."
rm -rf /var/lib/bitwarden_rs/*
rm -rf /var/lib/vaultwarden/*
# 7. Set sealed flag and make it immutable
echo "sovran-auto-seal: setting sealed flag..."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
# 8. Remove onboarded flag so onboarding runs fresh
rm -f /var/lib/sovran-customer-onboarded
echo "sovran-auto-seal: auto-seal complete. Continuing boot into onboarding."
'';
};
# ── Legacy security check: warn existing (pre-seal) machines ───────
systemd.services.sovran-legacy-security-check = {
description = "Check for legacy (pre-factory-seal) security status";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils pkgs.openssl ];
script = ''
# If sealed AND onboarded fully clean, nothing to do
[ -f /var/lib/sovran-factory-sealed ] && [ -f /var/lib/sovran-customer-onboarded ] && exit 0
# If sealed but not yet onboarded seal was run, customer hasn't finished setup yet, that's fine
[ -f /var/lib/sovran-factory-sealed ] && exit 0
# If onboarded but NOT sealed installer ran without factory seal!
if [ -f /var/lib/sovran-customer-onboarded ] && [ ! -f /var/lib/sovran-factory-sealed ]; then
mkdir -p /var/lib/sovran
echo "unsealed" > /var/lib/sovran/security-status
cat > /var/lib/sovran/security-warning << 'EOF'
This machine was set up without the factory seal process. Factory test data including SSH keys, database contents, and wallet information may still be present on this system. It is strongly recommended to back up any important data and re-install using a fresh ISO, or contact Sovran Systems support for assistance.
EOF
exit 0
fi
# If the user completed Hub onboarding, they've addressed security
[ -f /var/lib/sovran/onboarding-complete ] && exit 0
# If the free password has been changed from ALL known factory defaults, no warning needed
if [ -f /etc/shadow ]; then
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
STILL_DEFAULT=false
# If the salt field starts with "rounds=", we cannot extract the real salt
# with a simple cut treat as still-default for safety
if printf '%s' "$SALT" | grep -q '^rounds='; then
STILL_DEFAULT=true
else
for DEFAULT_PW in "free" "gosovransystems"; do
case "$ALGO_ID" in
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
*)
# Unknown hash algorithm treat as still-default for safety
STILL_DEFAULT=true
break
;;
esac
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
STILL_DEFAULT=true
break
fi
done
fi
if [ "$STILL_DEFAULT" = "false" ]; then
# Password was changed clear any legacy warning and exit
rm -f /var/lib/sovran/security-status /var/lib/sovran/security-warning
exit 0
fi
fi
fi
# No flags at all + secrets exist = legacy (pre-seal era) machine
if [ -f /var/lib/secrets/root-password ]; then
mkdir -p /var/lib/sovran
echo "legacy" > /var/lib/sovran/security-status
echo "This system was deployed before the factory seal feature. Your passwords may be known to the factory. Please change your passwords through the Sovran Hub." > /var/lib/sovran/security-warning
fi
'';
};
}

View File

@@ -95,20 +95,22 @@ in
''; '';
}; };
# ── 1b. Save 'free' password on first boot ───────────────── # ── 1b. Generate random 'free' password on first boot ──────
systemd.services.free-password-setup = { systemd.services.free-password-setup = {
description = "Save the initial 'free' user password"; description = "Generate and set a random 'free' user password";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
}; };
path = [ pkgs.coreutils ]; path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ];
script = '' script = ''
SECRET_FILE="/var/lib/secrets/free-password" SECRET_FILE="/var/lib/secrets/free-password"
if [ ! -f "$SECRET_FILE" ]; then if [ ! -f "$SECRET_FILE" ]; then
mkdir -p /var/lib/secrets mkdir -p /var/lib/secrets
echo "free" > "$SECRET_FILE" FREE_PASS=$(pwgen -s 20 1)
echo "free:$FREE_PASS" | chpasswd
echo "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE" chmod 600 "$SECRET_FILE"
fi fi
''; '';

View File

@@ -12,7 +12,6 @@
./core/sovran_systemsos-desktop.nix ./core/sovran_systemsos-desktop.nix
./core/sshd-localhost.nix ./core/sshd-localhost.nix
./core/sovran-hub.nix ./core/sovran-hub.nix
./core/factory-seal.nix
# ── Always on (no flag) ─────────────────────────────────── # ── Always on (no flag) ───────────────────────────────────
./php.nix ./php.nix