From 2fae4ccc796204eab4606e4d0771bce04be3d28a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:58:42 +0000 Subject: [PATCH] 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> --- app/sovran_systemsos_web/server.py | 285 ++++++++++------- .../static/css/security.css | 267 ++++++++++++++++ app/sovran_systemsos_web/static/js/events.js | 53 +++- .../static/js/features.js | 20 -- .../static/js/security.js | 205 +++++++++++- .../static/js/service-detail.js | 9 - app/sovran_systemsos_web/static/js/state.js | 5 - app/sovran_systemsos_web/static/js/tiles.js | 12 + app/sovran_systemsos_web/static/onboarding.js | 191 ++---------- .../templates/onboarding.html | 52 +--- modules/core/factory-seal.nix | 292 ------------------ modules/credentials.nix | 10 +- modules/modules.nix | 1 - 13 files changed, 743 insertions(+), 659 deletions(-) delete mode 100644 modules/core/factory-seal.nix diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index d37d3d3..9dcec66 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -61,10 +61,9 @@ REBOOT_COMMAND = ["reboot"] ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete" AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" -# ── Legacy security check constants ────────────────────────────── +# ── Security constants ──────────────────────────────────────────── -SECURITY_STATUS_FILE = "/var/lib/sovran/security-status" -SECURITY_WARNING_FILE = "/var/lib/sovran/security-warning" +SECURITY_BANNER_DISMISSED_FLAG = "/var/lib/sovran/security-banner-dismissed" # ── Tech Support constants ──────────────────────────────────────── @@ -2927,112 +2926,190 @@ async def api_domains_check(req: DomainCheckRequest): 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. - Returns {"status": "legacy", "warning": ""} for legacy machines, - or {"status": "ok", "warning": ""} when the files are absent. +@app.get("/api/security/banner-status") +async def api_security_banner_status(): + """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: - with open(SECURITY_STATUS_FILE, "r") as f: - status = f.read().strip() - except FileNotFoundError: - status = "ok" - - 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} + os.makedirs(os.path.dirname(SECURITY_BANNER_DISMISSED_FLAG), exist_ok=True) + open(SECURITY_BANNER_DISMISSED_FLAG, "w").close() + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not write dismiss flag: {exc}") + return {"ok": True} -def _is_free_password_default() -> bool: - """Check /etc/shadow directly to see if 'free' still has a factory default password. +@app.post("/api/security/reset") +async def api_security_reset(): + """Perform a full security reset. - Hashes each known factory default against the current shadow hash so that - password changes made via GNOME, passwd, or any method other than the Hub - are detected correctly. + Wipes secrets, LND wallet, SSH keys, drops databases, removes app configs, + clears Vaultwarden data, removes the onboarding-complete flag so onboarding + re-runs, and reboots the system. """ - import subprocess - import re as _re + wipe_paths = [ + "/var/lib/secrets", + "/var/lib/sovran", + "/root/.ssh", + "/home/free/.ssh", + "/var/lib/lnd", + "/var/lib/vaultwarden", + ] - FACTORY_DEFAULTS = ["free", "gosovransystems"] - # Map shadow algorithm IDs to openssl passwd flags (SHA-512 and SHA-256 only, - # matching the shell-script counterpart in factory-seal.nix) - ALGO_FLAGS = {"6": "-6", "5": "-5"} + errors: list[str] = [] + + # Wipe filesystem paths + 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: - with open("/etc/shadow", "r") as f: - for line in f: - parts = line.strip().split(":") - if parts[0] == "free" and len(parts) > 1: - current_hash = parts[1] - if not current_hash or current_hash in ("!", "*", "!!"): - return True # locked/no password — treat as default - # Parse hash: $id$[rounds=N$]salt$hash - hash_fields = current_hash.split("$") - # hash_fields: ["", id, salt_or_rounds, ...] - if len(hash_fields) < 4: - return True # unrecognized format — assume default for safety - algo_id = hash_fields[1] - salt_field = hash_fields[2] - if algo_id not in ALGO_FLAGS: - return True # unrecognized algorithm — assume default for safety - if salt_field.startswith("rounds="): - return True # can't extract real salt simply — assume default for safety - # Validate salt contains only safe characters (alphanumeric, '.', '/', '-', '_') - # to guard against unexpected shadow file content before passing to subprocess - if not _re.fullmatch(r"[A-Za-z0-9./\-_]+", salt_field): - return True # unexpected salt format — assume default for safety - openssl_flag = ALGO_FLAGS[algo_id] - for default_pw in FACTORY_DEFAULTS: - try: - result = subprocess.run( - ["openssl", "passwd", openssl_flag, "-salt", salt_field, default_pw], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0 and result.stdout.strip() == current_hash: - return True - except Exception: - return True # if openssl fails, assume default for safety - return False - except (FileNotFoundError, PermissionError): + result = subprocess.run( + ["sudo", "-u", "postgres", "psql", "-c", + "SELECT datname FROM pg_database WHERE datistemplate = false AND datname NOT IN ('postgres')"], + capture_output=True, text=True, timeout=30, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + dbname = line.strip() + if dbname and not dbname.startswith("-") and dbname != "datname": + subprocess.run( + ["sudo", "-u", "postgres", "dropdb", "--if-exists", dbname], + capture_output=True, text=True, timeout=30, + ) + except Exception as exc: + errors.append(f"postgres wipe: {exc}") + + # Drop MariaDB databases + try: + result = subprocess.run( + ["mysql", "-u", "root", "-e", + "SHOW DATABASES"], + capture_output=True, text=True, timeout=30, + ) + if result.returncode == 0: + skip = {"Database", "information_schema", "performance_schema", "mysql", "sys"} + for line in result.stdout.splitlines(): + dbname = line.strip() + if dbname and dbname not in skip: + subprocess.run( + ["mysql", "-u", "root", "-e", f"DROP DATABASE IF EXISTS `{dbname}`"], + capture_output=True, text=True, timeout=30, + ) + except Exception as exc: + errors.append(f"mariadb wipe: {exc}") + + # Reboot + try: + subprocess.Popen(["systemctl", "reboot"]) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Reboot failed: {exc}") + + 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 - 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") -async def api_password_is_default(): - """Check if the free account password is still the factory default. + # ── 3. Compare running system to flake build ────────────────── + system_matches = False + 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 - via GNOME Settings, the passwd command, or any other method are detected - correctly — not just changes made through the Hub or change-free-password. - """ - return {"is_default": _is_free_password_default()} + return { + "flake_commit": flake_commit, + "repo_url": repo_url, + "store_verified": store_verified, + "store_errors": store_errors, + "system_matches": system_matches, + "current_system_path": current_system_path, + "expected_system_path": expected_system_path, + } # ── 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 /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: 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: 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} diff --git a/app/sovran_systemsos_web/static/css/security.css b/app/sovran_systemsos_web/static/css/security.css index 7871552..7f8448c 100644 --- a/app/sovran_systemsos_web/static/css/security.css +++ b/app/sovran_systemsos_web/static/css/security.css @@ -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 ───────────────────────── */ .security-inline-banner { diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index efe405d..ad1236e 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -70,6 +70,47 @@ async function 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 = + '
' + + '\uD83D\uDEE1' + + '' + + 'Did someone else set up this machine? ' + + 'If this computer was pre-configured by another person, go to ' + + 'Menu \u2192 Security to reset all passwords and keys. ' + + 'This ensures only you have access.' + + '' + + '
' + + ''; + + 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 ────────────────────────────────────────────────────────── async function init() { @@ -84,8 +125,16 @@ async function init() { // If we can't reach the endpoint, continue to normal dashboard } - // Check for legacy machine security warning - await checkLegacySecurity(); + // Show first-login security banner only for machines that went through onboarding + // (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 { var cfg = await apiFetch("/api/config"); diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index 1dfc544..2bdc568 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -599,29 +599,9 @@ function renderAutolaunchToggle(enabled) { var section = document.createElement("div"); 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 = - '
' + - '' + - '' + msg + '' + - '' + linkText + '' + - '
'; - } - section.innerHTML = '
Preferences
' + '
' + - securityBanner + '
' + '
' + '
' + diff --git a/app/sovran_systemsos_web/static/js/security.js b/app/sovran_systemsos_web/static/js/security.js index d716960..3f357ad 100644 --- a/app/sovran_systemsos_web/static/js/security.js +++ b/app/sovran_systemsos_web/static/js/security.js @@ -1,16 +1,201 @@ "use strict"; -// ── Legacy security warning ─────────────────────────────────────── +// ── Security Modal ──────────────────────────────────────────────── -async function checkLegacySecurity() { - try { - var data = await apiFetch("/api/security/status"); - if (data && (data.status === "legacy" || data.status === "unsealed")) { - _securityIsLegacy = true; - _securityStatus = data.status; - _securityWarningMessage = data.warning || "This machine may have a security issue. Please review your system security."; +function openSecurityModal() { + if ($supportModal) $supportModal.classList.add("open"); + var title = document.getElementById("support-modal-title"); + if (title) title.textContent = "\uD83D\uDEE1 Security"; + + if ($supportBody) { + $supportBody.innerHTML = + // ── Section A: Security Reset ────────────────────────────── + '
' + + '

Security Reset

' + + '

' + + 'Run this if you are using this physical computer for the first time AND ' + + '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.' + + '

' + + '

' + + '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, you will lose your Bitcoin.' + + '

' + + '' + + '' + + '
' + + + '
' + + + // ── Section B: Verify System Integrity ──────────────────── + '
' + + '

Verify System Integrity

' + + '

' + + '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.' + + '

' + + '

This verification confirms three things:

' + + '
    ' + + '
  1. ' + + 'Source Code Match \u2014 The system configuration on this machine matches ' + + 'the exact commit published in the public repository. No hidden changes were added.' + + '
  2. ' + + '
  3. ' + + 'Binary Integrity \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.' + + '
  4. ' + + '
  5. ' + + 'Running System Match \u2014 The currently running system matches what the ' + + 'configuration says it should be. No unauthorized modifications are active.' + + '
  6. ' + + '
' + + '

' + + '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.' + + '

' + + '' + + '' + + '
'; + + // ── 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 = '

\u231B Running verification checks\u2026 This may take a few minutes.

'; + + try { + var data = await apiFetch("/api/security/verify-integrity", { method: "POST" }); + var html = '
'; + + // Flake commit + html += '
'; + html += 'Source Commit:'; + html += '' + escHtml(data.flake_commit || "unknown") + ''; + if (data.repo_url) { + html += 'View on Gitea \u2197'; + } + html += '
'; + + // Store verification + var storeOk = data.store_verified === true; + html += '
'; + html += 'Binary Integrity:'; + html += ''; + html += storeOk ? "\u2705 PASS" : "\u274C FAIL"; + html += ''; + html += '
'; + if (!storeOk && data.store_errors && data.store_errors.length > 0) { + html += '
Show errors (' + data.store_errors.length + ')'; + html += '
' + escHtml(data.store_errors.join("\n")) + '
'; + html += '
'; + } + + // System match + var sysOk = data.system_matches === true; + html += '
'; + html += 'Running System Match:'; + html += ''; + html += sysOk ? "\u2705 PASS" : "\u274C FAIL"; + html += ''; + html += '
'; + if (!sysOk) { + html += '
'; + html += 'Current:' + escHtml(data.current_system_path || "") + ''; + html += '
'; + html += '
'; + html += 'Expected:' + escHtml(data.expected_system_path || "") + ''; + html += '
'; + } + + html += '
'; + verifyResults.innerHTML = html; + } catch (err) { + verifyResults.innerHTML = '

\u274C Verification failed: ' + escHtml(err.message || "Unknown error") + '

'; + } + + verifyBtn.disabled = false; + verifyBtn.textContent = "Verify Now"; + }); } - } catch (_) { - // Non-fatal — silently ignore if the endpoint is unreachable } } diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 25cc4b5..98dd56f 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -612,15 +612,6 @@ function openSystemChangePasswordModal(unit, name, icon) { resultEl.textContent = "✅ System password changed successfully."; submitBtn.textContent = "Change Password"; 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) { resultEl.className = "matrix-form-result error"; resultEl.textContent = "❌ " + (err.message || "Failed to change password."); diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index c85947b..896c372 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -99,10 +99,5 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn"); const $upgradeCancelBtn = document.getElementById("upgrade-cancel-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 // (removed — health is now shown per-tile via the composite health field) \ No newline at end of file diff --git a/app/sovran_systemsos_web/static/js/tiles.js b/app/sovran_systemsos_web/static/js/tiles.js index 3d793d5..53eaddc 100644 --- a/app/sovran_systemsos_web/static/js/tiles.js +++ b/app/sovran_systemsos_web/static/js/tiles.js @@ -89,6 +89,18 @@ function renderSidebarSupport(supportServices) { backupBtn.addEventListener("click", function() { openBackupModal(); }); $sidebarSupport.appendChild(backupBtn); + // ── Security button + var securityBtn = document.createElement("button"); + securityBtn.className = "sidebar-support-btn"; + securityBtn.innerHTML = + '\uD83D\uDEE1' + + '' + + 'Security' + + 'Reset & verify system' + + ''; + securityBtn.addEventListener("click", function() { openSecurityModal(); }); + $sidebarSupport.appendChild(securityBtn); + // ── Upgrade button (Node role only) if (_currentRole === "node") { var upgradeBtn = document.createElement("button"); diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 6be1c39..b6bd2f4 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -1,25 +1,22 @@ /* 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"; // ── Constants ───────────────────────────────────────────────────── -const TOTAL_STEPS = 6; +const TOTAL_STEPS = 5; -// Steps to skip per role (steps 4 and 5 involve domain/port setup) -// Steps 2 (timezone/locale) and 3 (password) are NEVER skipped — all roles need them. +// Steps to skip per role (steps 3 and 4 involve domain/port setup) +// Step 2 (timezone/locale) is NEVER skipped — all roles need it. const ROLE_SKIP_STEPS = { - "desktop": [4, 5], - "node": [4, 5], + "desktop": [3, 4], + "node": [3, 4], }; // ── Role state (loaded at init) ─────────────────────────────────── 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 const DOMAIN_DEFS = [ { name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true }, @@ -96,8 +93,7 @@ function showStep(step) { if (step === 2) loadStep2(); if (step === 3) loadStep3(); if (step === 4) loadStep4(); - if (step === 5) loadStep5(); - // Step 6 (Complete) is static — no lazy-load needed + // Step 5 (Complete) is static — no lazy-load needed } // Return the next step number, skipping over role-excluded steps @@ -273,135 +269,12 @@ async function saveStep2() { return true; } -// ── Step 3: Create Your Password ───────────────────────────────── +// ── Step 3: Domain Configuration ───────────────────────────────── async function loadStep3() { var body = document.getElementById("step-3-body"); 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 = - '
' + - '' + - '
' + - '' + - '' + - '
' + - '

Minimum 8 characters

' + - '
' + - '
' + - '' + - '
' + - '' + - '' + - '
' + - '
' + - '
⚠️ Write this password down — it cannot be recovered.
'; - - // 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 = - '
✅ Your password was already set during installation.
' + - '
' + - 'Change it anyway' + - '
' + - '
' + - '' + - '
' + - '' + - '' + - '
' + - '

Minimum 8 characters

' + - '
' + - '
' + - '' + - '
' + - '' + - '' + - '
' + - '
' + - '
⚠️ Write this password down — it cannot be recovered.
' + - '
' + - '
'; - - // 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 { // Fetch services, domains, and network info in parallel var results = await Promise.all([ @@ -542,8 +415,8 @@ async function loadStep4() { }); } -async function saveStep4() { - setStatus("step-4-status", "Saving domains…", "info"); +async function saveStep3() { + setStatus("step-3-status", "Saving domains…", "info"); var errors = []; // Save each domain input @@ -583,18 +456,18 @@ async function saveStep4() { } if (errors.length > 0) { - setStatus("step-4-status", "⚠ Some errors: " + errors.join("; "), "error"); + setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error"); return false; } - setStatus("step-4-status", "✓ Saved", "ok"); + setStatus("step-3-status", "✓ Saved", "ok"); return true; } -// ── Step 5: Port Forwarding ─────────────────────────────────────── +// ── Step 4: Port Forwarding ─────────────────────────────────────── -async function loadStep5() { - var body = document.getElementById("step-5-body"); +async function loadStep4() { + var body = document.getElementById("step-4-body"); if (!body) return; body.innerHTML = '

Checking ports…

'; @@ -675,10 +548,10 @@ async function loadStep5() { body.innerHTML = html; } -// ── Step 6: Complete ────────────────────────────────────────────── +// ── Step 5: Complete ────────────────────────────────────────────── 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…"; } try { @@ -709,36 +582,24 @@ function wireNavButtons() { if (ok) showStep(nextStep(2)); }); - // Step 3 → 4 (save password first) + // Step 3 → 4 (save domains first) var s3next = document.getElementById("step-3-next"); if (s3next) s3next.addEventListener("click", async function() { s3next.disabled = true; - var origText = s3next.textContent; s3next.textContent = "Saving…"; - var ok = await saveStep3(); + await saveStep3(); s3next.disabled = false; - s3next.textContent = origText; - if (ok) showStep(nextStep(3)); + s3next.textContent = "Save & Continue →"; + showStep(nextStep(3)); }); - // Step 4 → 5 (save domains first) + // Step 4 → 5 (port forwarding — no save needed) var s4next = document.getElementById("step-4-next"); - if (s4next) s4next.addEventListener("click", async function() { - s4next.disabled = true; - s4next.textContent = "Saving…"; - await saveStep4(); - s4next.disabled = false; - s4next.textContent = "Save & Continue →"; - showStep(nextStep(4)); - }); + if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); }); - // Step 5 → 6 (port forwarding — no save needed) - var s5next = document.getElementById("step-5-next"); - if (s5next) s5next.addEventListener("click", function() { showStep(nextStep(5)); }); - - // Step 6: finish - var s6finish = document.getElementById("step-6-finish"); - if (s6finish) s6finish.addEventListener("click", completeOnboarding); + // Step 5: finish + var s5finish = document.getElementById("step-5-finish"); + if (s5finish) s5finish.addEventListener("click", completeOnboarding); // Back buttons document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index e33481e..39ac22f 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -36,8 +36,6 @@ 4 5 - - 6
@@ -96,29 +94,8 @@
- + - - -