From 2c061bc6d13cb9bfcc5f220abb2ec5afc16b2a3f Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 19 Oct 2025 16:33:42 -0600 Subject: [PATCH] Fixed Inventory Role Authentication --- AGENTS.md | 3 +- Data/Agent/Roles/role_DeviceAudit.py | 19 +++++++++- Data/Agent/agent.py | 10 ++++-- Data/Agent/security.py | 54 ++++++++++++++++++++++++++-- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d4c2fc2..0fd57a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Today the stable core focuses on workflow-driven API and automation scenarios. R Agents establish TLS-secured REST calls to the Flask backend on port 5000 and keep an authenticated WebSocket session for interactive features such as screenshot capture. Future plans include WebRTC for higher-performance remote desktop. Every agent now performs an enrollment handshake (see **Secure Enrollment & Tokens** below) prior to opening either channel; all API access is bound to short-lived Ed25519-signed JWTs. ### Secure Enrollment & Tokens -- On first launch the agent generates an Ed25519 identity and stores the private key under `Agent/Borealis/Settings/agent_key.ed25519` (protected with DPAPI on Windows or chmod 600 elsewhere). The public key is retained as SPKI DER and fingerprinted with SHA-256. +- On first launch the agent generates an Ed25519 identity and stores the private key under `Certificates/Agent/Identity//agent_identity_private.ed25519` (protected with DPAPI on Windows or chmod 600 elsewhere). The public key is retained alongside it as Base64 (`agent_identity_public.ed25519`) and fingerprinted with SHA-256. - Enrollment starts with an installer code (minted in the Web UI) and proves key possession by signing the server nonce. Upon operator approval the server issues: - The canonical device GUID (persisted to `guid.txt` alongside the key material). - A short-lived access token (EdDSA/JWT) and a long-lived refresh token (stored encrypted via DPAPI and hashed server-side). @@ -204,4 +204,3 @@ This section summarizes what is considered usable vs. experimental today. - diff --git a/Data/Agent/Roles/role_DeviceAudit.py b/Data/Agent/Roles/role_DeviceAudit.py index c5e646d..d5bd715 100644 --- a/Data/Agent/Roles/role_DeviceAudit.py +++ b/Data/Agent/Roles/role_DeviceAudit.py @@ -983,13 +983,30 @@ class Role: # Always post the latest available details (possibly cached) details_to_send = self._last_details or {'summary': collect_summary(self.ctx.config)} get_url = (self.ctx.hooks.get('get_server_url') if isinstance(self.ctx.hooks, dict) else None) or (lambda: 'http://localhost:5000') - url = (get_url() or '').rstrip('/') + '/api/agent/details' payload = { 'agent_id': self.ctx.agent_id, 'hostname': details_to_send.get('summary', {}).get('hostname', socket.gethostname()), 'details': details_to_send, } + client_factory = None + if isinstance(self.ctx.hooks, dict): + client_factory = self.ctx.hooks.get('http_client') + + if callable(client_factory): + try: + client = client_factory() + except Exception: + client = None + else: + try: + await client.async_post_json("/api/agent/details", payload, require_auth=True) + await asyncio.sleep(interval_sec) + continue + except Exception: + pass + if aiohttp is not None: + url = (get_url() or '').rstrip('/') + '/api/agent/details' async with aiohttp.ClientSession() as session: await session.post(url, json=payload, timeout=10) except Exception: diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index 351092c..d20c1b8 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -236,9 +236,9 @@ INSTALLER_CODE_OVERRIDE = ( def _agent_guid_path() -> str: try: root = _find_project_root() - return os.path.join(root, 'Agent', 'Borealis', 'agent_GUID') + return os.path.join(root, 'Agent', 'Borealis', 'Settings', 'Agent_GUID.txt') except Exception: - return os.path.abspath(os.path.join(os.path.dirname(__file__), 'agent_GUID')) + return os.path.abspath(os.path.join(os.path.dirname(__file__), 'Settings', 'Agent_GUID.txt')) def _settings_dir(): @@ -3068,7 +3068,11 @@ if __name__=='__main__': _log_agent(f'Authentication bootstrap failed: {exc}', fname='agent.error.log') print(f"[WARN] Authentication bootstrap failed: {exc}") try: - base_hooks = {'send_service_control': send_service_control, 'get_server_url': get_server_url} + base_hooks = { + 'send_service_control': send_service_control, + 'get_server_url': get_server_url, + 'http_client': http_client, + } if not SYSTEM_SERVICE_MODE: # Load interactive-context roles (tray/UI, current-user execution, screenshot, etc.) hooks_interactive = {**base_hooks, 'service_mode': 'currentuser'} diff --git a/Data/Agent/security.py b/Data/Agent/security.py index 108782d..acf1d83 100644 --- a/Data/Agent/security.py +++ b/Data/Agent/security.py @@ -99,6 +99,51 @@ def _resolve_agent_certificate_dir(settings_dir: str, scope: str) -> str: return str(target) +def _resolve_agent_identity_dir(settings_dir: str, scope: str) -> str: + scope_name = (scope or "CURRENTUSER").strip().upper() or "CURRENTUSER" + + def _as_path(value: Optional[str]) -> Optional[Path]: + if not value: + return None + try: + return Path(value).expanduser().resolve() + except Exception: + try: + return Path(value).expanduser() + except Exception: + return Path(value) + + env_agent_root = _as_path(os.environ.get("BOREALIS_AGENT_CERT_ROOT")) + env_cert_root = _as_path(os.environ.get("BOREALIS_CERTIFICATES_ROOT")) or _as_path( + os.environ.get("BOREALIS_CERT_ROOT") + ) + + if env_agent_root is not None: + base = env_agent_root + elif env_cert_root is not None: + base = env_cert_root / "Agent" + else: + settings_path = Path(settings_dir).resolve() + try: + project_root = settings_path.parents[2] + except Exception: + project_root = settings_path.parent + base = project_root / "Certificates" / "Agent" + + target = base / "Identity" + if scope_name in {"SYSTEM", "CURRENTUSER"}: + target = target / scope_name + elif scope_name: + target = target / scope_name + + try: + target.mkdir(parents=True, exist_ok=True) + except Exception: + pass + + return str(target) + + class _FileLock: def __init__(self, path: str) -> None: self.path = path @@ -274,9 +319,11 @@ class AgentKeyStore: self.scope_system = self.scope_name == "SYSTEM" _ensure_dir(self.settings_dir) self._certificate_dir = _resolve_agent_certificate_dir(self.settings_dir, self.scope_name) - self._private_path = os.path.join(self.settings_dir, "agent_key.ed25519") - self._public_path = os.path.join(self.settings_dir, "agent_key.pub") - self._guid_path = os.path.join(self.settings_dir, "guid.txt") + self._identity_dir = _resolve_agent_identity_dir(self.settings_dir, self.scope_name) + _ensure_dir(self._identity_dir) + self._private_path = os.path.join(self._identity_dir, "agent_identity_private.ed25519") + self._public_path = os.path.join(self._identity_dir, "agent_identity_public.ed25519") + self._guid_path = os.path.join(self.settings_dir, "Agent_GUID.txt") self._access_token_path = os.path.join(self.settings_dir, "access.jwt") self._refresh_token_path = os.path.join(self.settings_dir, "refresh.token") self._token_meta_path = os.path.join(self.settings_dir, "access.meta.json") @@ -316,6 +363,7 @@ class AgentKeyStore: return AgentIdentity(private_key=private_key, public_key_der=public_der, public_key_b64=public_b64, fingerprint=fingerprint) def _create_identity(self) -> AgentIdentity: + _ensure_dir(os.path.dirname(self._private_path)) private_key = ed25519.Ed25519PrivateKey.generate() private_bytes = private_key.private_bytes( serialization.Encoding.PEM,