From 418e99c8c0e13dcea08866cd29c86f9464d0370c Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Fri, 17 Oct 2025 20:44:26 -0600 Subject: [PATCH] Fix agent keystore initialization order --- Data/Agent/agent.py | 35 +++++----- Data/Agent/security.py | 8 ++- Data/Server/Modules/auth/jwt_service.py | 28 +++++++- Data/Server/Modules/crypto/certificates.py | 6 +- Data/Server/Modules/crypto/signing.py | 34 +++++++++- Data/Server/Modules/runtime.py | 78 ++++++++++++++++++++++ Data/Server/WebUI/vite.config.mts | 8 ++- Data/Server/server.py | 36 ++++++++++ 8 files changed, 205 insertions(+), 28 deletions(-) create mode 100644 Data/Server/Modules/runtime.py diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index 3deef9a..5bd915a 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -125,6 +125,24 @@ def _agent_guid_path() -> str: return os.path.abspath(os.path.join(os.path.dirname(__file__), 'agent_GUID')) +def _settings_dir(): + try: + return os.path.join(_find_project_root(), 'Agent', 'Borealis', 'Settings') + except Exception: + return os.path.abspath(os.path.join(os.path.dirname(__file__), 'Settings')) + + +_KEY_STORE_INSTANCE = None + + +def _key_store() -> AgentKeyStore: + global _KEY_STORE_INSTANCE + if _KEY_STORE_INSTANCE is None: + scope = 'SYSTEM' if SYSTEM_SERVICE_MODE else 'CURRENTUSER' + _KEY_STORE_INSTANCE = AgentKeyStore(_settings_dir(), scope=scope) + return _KEY_STORE_INSTANCE + + def _persist_agent_guid_local(guid: str): guid = _normalize_agent_guid(guid) if not guid: @@ -1029,23 +1047,6 @@ def _collect_heartbeat_metrics() -> Dict[str, Any]: def _settings_dir(): - try: - return os.path.join(_find_project_root(), 'Agent', 'Borealis', 'Settings') - except Exception: - return os.path.abspath(os.path.join(os.path.dirname(__file__), 'Settings')) - - -_KEY_STORE_INSTANCE = None - - -def _key_store() -> AgentKeyStore: - global _KEY_STORE_INSTANCE - if _KEY_STORE_INSTANCE is None: - scope = 'SYSTEM' if SYSTEM_SERVICE_MODE else 'CURRENTUSER' - _KEY_STORE_INSTANCE = AgentKeyStore(_settings_dir(), scope=scope) - return _KEY_STORE_INSTANCE - - SERVER_CERT_PATH = _key_store().server_certificate_path() diff --git a/Data/Agent/security.py b/Data/Agent/security.py index e3feb9d..890c005 100644 --- a/Data/Agent/security.py +++ b/Data/Agent/security.py @@ -39,7 +39,9 @@ def _restrict_permissions(path: str) -> None: def _protect(data: bytes, *, scope_system: bool) -> bytes: if not IS_WINDOWS or not win32crypt: return data - flags = win32crypt.CRYPTPROTECT_LOCAL_MACHINE if scope_system else 0 + flags = 0 + if scope_system: + flags = getattr(win32crypt, "CRYPTPROTECT_LOCAL_MACHINE", 0x4) protected = win32crypt.CryptProtectData(data, None, None, None, None, flags) # type: ignore[attr-defined] return protected[1] @@ -47,7 +49,9 @@ def _protect(data: bytes, *, scope_system: bool) -> bytes: def _unprotect(data: bytes, *, scope_system: bool) -> bytes: if not IS_WINDOWS or not win32crypt: return data - flags = win32crypt.CRYPTPROTECT_LOCAL_MACHINE if scope_system else 0 + flags = 0 + if scope_system: + flags = getattr(win32crypt, "CRYPTPROTECT_LOCAL_MACHINE", 0x4) unwrapped = win32crypt.CryptUnprotectData(data, None, None, None, None, flags) # type: ignore[attr-defined] return unwrapped[1] diff --git a/Data/Server/Modules/auth/jwt_service.py b/Data/Server/Modules/auth/jwt_service.py index 9d77859..ab5640b 100644 --- a/Data/Server/Modules/auth/jwt_service.py +++ b/Data/Server/Modules/auth/jwt_service.py @@ -7,15 +7,17 @@ from __future__ import annotations import hashlib import time from datetime import datetime, timezone -from pathlib import Path from typing import Any, Dict, Optional import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 -_KEY_DIR = Path(__file__).resolve().parent.parent / "keys" +from Modules.runtime import ensure_runtime_dir, runtime_path + +_KEY_DIR = runtime_path("auth_keys") _KEY_FILE = _KEY_DIR / "borealis-jwt-ed25519.key" +_LEGACY_KEY_FILE = runtime_path("keys") / "borealis-jwt-ed25519.key" class JWTService: @@ -96,11 +98,17 @@ def load_service() -> JWTService: def _load_or_create_private_key() -> ed25519.Ed25519PrivateKey: - _KEY_DIR.mkdir(parents=True, exist_ok=True) + ensure_runtime_dir("auth_keys") + _migrate_legacy_key_if_present() + if _KEY_FILE.exists(): with _KEY_FILE.open("rb") as fh: return serialization.load_pem_private_key(fh.read(), password=None) + if _LEGACY_KEY_FILE.exists(): + with _LEGACY_KEY_FILE.open("rb") as fh: + return serialization.load_pem_private_key(fh.read(), password=None) + private_key = ed25519.Ed25519PrivateKey.generate() pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, @@ -116,3 +124,17 @@ def _load_or_create_private_key() -> ed25519.Ed25519PrivateKey: pass return private_key + +def _migrate_legacy_key_if_present() -> None: + if not _LEGACY_KEY_FILE.exists() or _KEY_FILE.exists(): + return + + try: + ensure_runtime_dir("auth_keys") + try: + _LEGACY_KEY_FILE.replace(_KEY_FILE) + except Exception: + _KEY_FILE.write_bytes(_LEGACY_KEY_FILE.read_bytes()) + except Exception: + return + diff --git a/Data/Server/Modules/crypto/certificates.py b/Data/Server/Modules/crypto/certificates.py index b50c40f..ff6b1db 100644 --- a/Data/Server/Modules/crypto/certificates.py +++ b/Data/Server/Modules/crypto/certificates.py @@ -19,7 +19,9 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID -_CERT_DIR = Path(__file__).resolve().parent.parent / "certs" +from Modules.runtime import ensure_runtime_dir, runtime_path + +_CERT_DIR = runtime_path("certs") _CERT_FILE = _CERT_DIR / "borealis-server-cert.pem" _KEY_FILE = _CERT_DIR / "borealis-server-key.pem" _BUNDLE_FILE = _CERT_DIR / "borealis-server-bundle.pem" @@ -35,7 +37,7 @@ def ensure_certificate(common_name: str = "Borealis Server") -> Tuple[Path, Path Returns (cert_path, key_path, bundle_path). """ - _CERT_DIR.mkdir(parents=True, exist_ok=True) + ensure_runtime_dir("certs") regenerate = not (_CERT_FILE.exists() and _KEY_FILE.exists()) if not regenerate: diff --git a/Data/Server/Modules/crypto/signing.py b/Data/Server/Modules/crypto/signing.py index 18403c9..b74e537 100644 --- a/Data/Server/Modules/crypto/signing.py +++ b/Data/Server/Modules/crypto/signing.py @@ -10,11 +10,15 @@ from typing import Tuple from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 +from Modules.runtime import ensure_runtime_dir, runtime_path + from .keys import base64_from_spki_der -_KEY_DIR = Path(__file__).resolve().parent.parent / "keys" +_KEY_DIR = runtime_path("script_signing_keys") _SIGNING_KEY_FILE = _KEY_DIR / "borealis-script-ed25519.key" _SIGNING_PUB_FILE = _KEY_DIR / "borealis-script-ed25519.pub" +_LEGACY_KEY_FILE = runtime_path("keys") / "borealis-script-ed25519.key" +_LEGACY_PUB_FILE = runtime_path("keys") / "borealis-script-ed25519.pub" class ScriptSigner: @@ -41,11 +45,17 @@ def load_signer() -> ScriptSigner: def _load_or_create() -> ed25519.Ed25519PrivateKey: - _KEY_DIR.mkdir(parents=True, exist_ok=True) + ensure_runtime_dir("script_signing_keys") + _migrate_legacy_material_if_present() + if _SIGNING_KEY_FILE.exists(): with _SIGNING_KEY_FILE.open("rb") as fh: return serialization.load_pem_private_key(fh.read(), password=None) + if _LEGACY_KEY_FILE.exists(): + with _LEGACY_KEY_FILE.open("rb") as fh: + return serialization.load_pem_private_key(fh.read(), password=None) + private_key = ed25519.Ed25519PrivateKey.generate() pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, @@ -68,3 +78,23 @@ def _load_or_create() -> ed25519.Ed25519PrivateKey: return private_key + +def _migrate_legacy_material_if_present() -> None: + if not _LEGACY_KEY_FILE.exists() or _SIGNING_KEY_FILE.exists(): + return + + try: + ensure_runtime_dir("script_signing_keys") + try: + _LEGACY_KEY_FILE.replace(_SIGNING_KEY_FILE) + except Exception: + _SIGNING_KEY_FILE.write_bytes(_LEGACY_KEY_FILE.read_bytes()) + + if _LEGACY_PUB_FILE.exists() and not _SIGNING_PUB_FILE.exists(): + try: + _LEGACY_PUB_FILE.replace(_SIGNING_PUB_FILE) + except Exception: + _SIGNING_PUB_FILE.write_bytes(_LEGACY_PUB_FILE.read_bytes()) + except Exception: + return + diff --git a/Data/Server/Modules/runtime.py b/Data/Server/Modules/runtime.py new file mode 100644 index 0000000..822c994 --- /dev/null +++ b/Data/Server/Modules/runtime.py @@ -0,0 +1,78 @@ +"""Utility helpers for locating runtime storage paths. + +The Borealis repository keeps the authoritative source code under ``Data/`` +so that the bootstrap scripts can copy those assets into sibling ``Server/`` +and ``Agent/`` directories for execution. Runtime artefacts such as TLS +certificates or signing keys must therefore live outside ``Data`` to avoid +polluting the template tree. This module centralises the path selection so +other modules can rely on a consistent location regardless of whether they +are executed from the copied runtime directory or directly from ``Data`` +during development. +""" + +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path +from typing import Optional + + +def _env_path(name: str) -> Optional[Path]: + """Return a resolved ``Path`` for the given environment variable.""" + + value = os.environ.get(name) + if not value: + return None + try: + return Path(value).expanduser().resolve() + except Exception: + return None + + +@lru_cache(maxsize=None) +def project_root() -> Path: + """Best-effort detection of the repository root.""" + + env = _env_path("BOREALIS_PROJECT_ROOT") + if env: + return env + + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / "Borealis.ps1").exists() or (parent / ".git").is_dir(): + return parent + + # Fallback to the ancestor that corresponds to ``/`` when the module + # lives under ``Data/Server/Modules``. + try: + return current.parents[4] + except IndexError: + return current.parent + + +@lru_cache(maxsize=None) +def server_runtime_root() -> Path: + """Location where the running server stores mutable artefacts.""" + + env = _env_path("BOREALIS_SERVER_ROOT") + if env: + return env + + root = project_root() + runtime = root / "Server" / "Borealis" + return runtime + + +def runtime_path(*parts: str) -> Path: + """Return a path relative to the server runtime root.""" + + return server_runtime_root().joinpath(*parts) + + +def ensure_runtime_dir(*parts: str) -> Path: + """Create (if required) and return a runtime directory.""" + + path = runtime_path(*parts) + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/Data/Server/WebUI/vite.config.mts b/Data/Server/WebUI/vite.config.mts index c43aa0a..09be004 100644 --- a/Data/Server/WebUI/vite.config.mts +++ b/Data/Server/WebUI/vite.config.mts @@ -4,16 +4,20 @@ import react from '@vitejs/plugin-react'; import path from 'path'; import fs from 'fs'; +const runtimeCertDir = process.env.BOREALIS_CERT_DIR; + const certCandidates = [ process.env.BOREALIS_TLS_CERT, + runtimeCertDir && path.resolve(runtimeCertDir, 'borealis-server-cert.pem'), path.resolve(__dirname, '../certs/borealis-server-cert.pem'), - path.resolve(__dirname, '../../Data/Server/certs/borealis-server-cert.pem'), + path.resolve(__dirname, '../../../Server/Borealis/certs/borealis-server-cert.pem'), ] as const; const keyCandidates = [ process.env.BOREALIS_TLS_KEY, + runtimeCertDir && path.resolve(runtimeCertDir, 'borealis-server-key.pem'), path.resolve(__dirname, '../certs/borealis-server-key.pem'), - path.resolve(__dirname, '../../Data/Server/certs/borealis-server-key.pem'), + path.resolve(__dirname, '../../../Server/Borealis/certs/borealis-server-key.pem'), ] as const; const pickFirst = (candidates: readonly (string | undefined)[]) => { diff --git a/Data/Server/server.py b/Data/Server/server.py index f657bef..346562d 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -20,6 +20,8 @@ Section Guide: import os import sys +from pathlib import Path +import ssl # Ensure the modular server package is importable when the runtime is launched # from a packaged directory (e.g., Server/Borealis). We look for the canonical @@ -40,6 +42,36 @@ eventlet.monkey_patch(thread=False) from eventlet import tpool +try: + from eventlet.wsgi import HttpProtocol # type: ignore +except Exception: + HttpProtocol = None # type: ignore[assignment] +else: + _original_handle_one_request = HttpProtocol.handle_one_request + + def _quiet_tls_http_mismatch(self): # type: ignore[override] + try: + return _original_handle_one_request(self) + except ssl.SSLError as exc: # type: ignore[arg-type] + reason = getattr(exc, "reason", "") + reason_text = str(reason).lower() if reason else "" + message = " ".join(str(arg) for arg in exc.args if arg).lower() + if "http_request" in message or reason_text == "http request": + try: + self.close_connection = True # type: ignore[attr-defined] + except Exception: + pass + try: + conn = getattr(self, "socket", None) or getattr(self, "connection", None) + if conn: + conn.close() + except Exception: + pass + return None + raise + + HttpProtocol.handle_one_request = _quiet_tls_http_mismatch # type: ignore[assignment] + import requests import re import base64 @@ -167,6 +199,10 @@ TLS_CERT_PATH, TLS_KEY_PATH, TLS_BUNDLE_PATH = certificates.certificate_paths() os.environ.setdefault("BOREALIS_TLS_CERT", TLS_CERT_PATH) os.environ.setdefault("BOREALIS_TLS_KEY", TLS_KEY_PATH) os.environ.setdefault("BOREALIS_TLS_BUNDLE", TLS_BUNDLE_PATH) +try: + os.environ.setdefault("BOREALIS_CERT_DIR", str(Path(TLS_CERT_PATH).resolve().parent)) +except Exception: + pass JWT_SERVICE = jwt_service_module.load_service() SCRIPT_SIGNER = signing.load_signer()