From 03bb19ef05b225d7c3332441b9b30178dd60207b Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 19 Oct 2025 19:55:19 -0600 Subject: [PATCH] Incorporated Script Code-Signing --- .../Roles/role_ScriptExec_CURRENTUSER.py | 53 +++++++- Data/Agent/Roles/role_ScriptExec_SYSTEM.py | 50 +++++++- Data/Agent/agent.py | 94 +++++++------- Data/Agent/signature_utils.py | 119 ++++++++++++++++++ Data/Server/job_scheduler.py | 30 ++++- Data/Server/server.py | 21 +++- 6 files changed, 313 insertions(+), 54 deletions(-) create mode 100644 Data/Agent/signature_utils.py diff --git a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py index c2f5f72..0cf0a04 100644 --- a/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py +++ b/Data/Agent/Roles/role_ScriptExec_CURRENTUSER.py @@ -8,6 +8,7 @@ import base64 from typing import Dict, List, Optional from PyQt5 import QtWidgets, QtGui +from signature_utils import decode_script_bytes, verify_and_store_script_signature ROLE_NAME = 'script_exec_currentuser' ROLE_CONTEXTS = ['interactive'] @@ -277,7 +278,55 @@ class Role: job_id = payload.get('job_id') script_type = (payload.get('script_type') or '').lower() run_mode = (payload.get('run_mode') or 'current_user').lower() - content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding')) + if run_mode == 'system': + return + script_bytes = decode_script_bytes(payload.get('script_content'), payload.get('script_encoding')) + if script_bytes is None: + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Invalid script payload (unable to decode)', + }) + return + signature_b64 = payload.get('signature') + sig_alg = (payload.get('sig_alg') or 'ed25519').lower() + signing_key = payload.get('signing_key') + if sig_alg and sig_alg not in ('ed25519', 'eddsa'): + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': f'Unsupported script signature algorithm: {sig_alg}', + }) + return + if not isinstance(signature_b64, str) or not signature_b64.strip(): + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Missing script signature; rejecting payload', + }) + return + http_client_fn = getattr(self.ctx, 'hooks', {}).get('http_client') if hasattr(self.ctx, 'hooks') else None + client = http_client_fn() if callable(http_client_fn) else None + if client is None: + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Signature verification unavailable (client missing)', + }) + return + if not verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key): + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Rejected script payload due to invalid signature', + }) + return + content = script_bytes.decode('utf-8', errors='replace') raw_env = payload.get('environment') env_map = _sanitize_env_map(raw_env) variables = payload.get('variables') if isinstance(payload.get('variables'), list) else [] @@ -302,8 +351,6 @@ class Role: timeout_seconds = max(0, int(payload.get('timeout_seconds') or 0)) except Exception: timeout_seconds = 0 - if run_mode == 'system': - return if script_type != 'powershell': await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" }) return diff --git a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py index c0dbacb..f8fe1f3 100644 --- a/Data/Agent/Roles/role_ScriptExec_SYSTEM.py +++ b/Data/Agent/Roles/role_ScriptExec_SYSTEM.py @@ -8,6 +8,8 @@ import subprocess import base64 from typing import Dict, List, Optional +from signature_utils import decode_script_bytes, verify_and_store_script_signature + ROLE_NAME = 'script_exec_system' ROLE_CONTEXTS = ['system'] @@ -293,7 +295,53 @@ class Role: return job_id = payload.get('job_id') script_type = (payload.get('script_type') or '').lower() - content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding')) + script_bytes = decode_script_bytes(payload.get('script_content'), payload.get('script_encoding')) + if script_bytes is None: + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Invalid script payload (unable to decode)', + }) + return + signature_b64 = payload.get('signature') + sig_alg = (payload.get('sig_alg') or 'ed25519').lower() + signing_key = payload.get('signing_key') + if sig_alg and sig_alg not in ('ed25519', 'eddsa'): + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': f'Unsupported script signature algorithm: {sig_alg}', + }) + return + if not isinstance(signature_b64, str) or not signature_b64.strip(): + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Missing script signature; rejecting payload', + }) + return + http_client_fn = getattr(self.ctx, 'hooks', {}).get('http_client') if hasattr(self.ctx, 'hooks') else None + client = http_client_fn() if callable(http_client_fn) else None + if client is None: + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Signature verification unavailable (client missing)', + }) + return + if not verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key): + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': 'Rejected script payload due to invalid signature', + }) + return + content = script_bytes.decode('utf-8', errors='replace') raw_env = payload.get('environment') env_map = _sanitize_env_map(raw_env) variables = payload.get('variables') if isinstance(payload.get('variables'), list) else [] diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index ac3d0ff..1c9eadb 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -36,6 +36,7 @@ import aiohttp import socketio from security import AgentKeyStore +from signature_utils import decode_script_bytes as _decode_script_bytes, verify_and_store_script_signature as _verify_and_store_script_signature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 @@ -2339,47 +2340,6 @@ async def send_heartbeat(): await asyncio.sleep(60) -def _verify_and_store_script_signature( - client: AgentHttpClient, - script_bytes: bytes, - signature_b64: str, - signing_key_hint: Optional[str] = None, -) -> bool: - candidates: List[str] = [] - if isinstance(signing_key_hint, str) and signing_key_hint.strip(): - candidates.append(signing_key_hint.strip()) - stored_key = client.load_server_signing_key() - if stored_key: - key_text = stored_key.strip() - if key_text and key_text not in candidates: - candidates.append(key_text) - for key_b64 in candidates: - try: - key_der = base64.b64decode(key_b64, validate=True) - except Exception: - continue - try: - public_key = serialization.load_der_public_key(key_der) - except Exception: - continue - if not isinstance(public_key, ed25519.Ed25519PublicKey): - continue - try: - signature = base64.b64decode(signature_b64, validate=True) - except Exception: - return False - try: - public_key.verify(signature, script_bytes) - if stored_key and stored_key.strip() != key_b64: - client.store_server_signing_key(key_b64) - elif not stored_key: - client.store_server_signing_key(key_b64) - return True - except Exception: - continue - return False - - async def poll_script_requests(): await asyncio.sleep(20) client = http_client() @@ -2394,9 +2354,9 @@ async def poll_script_requests(): signature_b64 = response.get("signature") sig_alg = (response.get("sig_alg") or "").lower() if script_b64 and signature_b64: - script_bytes = _decode_base64_bytes(script_b64) + script_bytes = _decode_script_bytes(script_b64, "base64") if script_bytes is None: - _log_agent('received script payload with invalid base64 encoding', fname='agent.error.log') + _log_agent('received script payload with invalid encoding', fname='agent.error.log') elif sig_alg and sig_alg not in ("ed25519", "eddsa"): _log_agent(f'unsupported script signature algorithm: {sig_alg}', fname='agent.error.log') else: @@ -3233,8 +3193,54 @@ if __name__=='__main__': return job_id = payload.get('job_id') script_type = (payload.get('script_type') or '').lower() - content = _decode_script_payload(payload.get('script_content'), payload.get('script_encoding')) + encoding_hint = payload.get('script_encoding') + script_bytes = _decode_script_bytes(payload.get('script_content'), encoding_hint) run_mode = (payload.get('run_mode') or 'current_user').lower() + if script_bytes is None: + err = 'Invalid script payload (unable to decode)' + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': err, + }) + _log_agent(err, fname='agent.error.log') + return + signature_b64 = payload.get('signature') + sig_alg = (payload.get('sig_alg') or 'ed25519').lower() + signing_key = payload.get('signing_key') + if sig_alg and sig_alg not in ('ed25519', 'eddsa'): + err = f"Unsupported script signature algorithm: {sig_alg}" + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': err, + }) + _log_agent(err, fname='agent.error.log') + return + if not isinstance(signature_b64, str) or not signature_b64.strip(): + err = 'Missing script signature; rejecting payload' + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': err, + }) + _log_agent(err, fname='agent.error.log') + return + client = http_client() + if not _verify_and_store_script_signature(client, script_bytes, signature_b64, signing_key): + err = 'Rejected script payload due to invalid signature' + await sio.emit('quick_job_result', { + 'job_id': job_id, + 'status': 'Failed', + 'stdout': '', + 'stderr': err, + }) + _log_agent(err, fname='agent.error.log') + return + content = script_bytes.decode('utf-8', errors='replace') if script_type != 'powershell': await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" }) return diff --git a/Data/Agent/signature_utils.py b/Data/Agent/signature_utils.py new file mode 100644 index 0000000..377bbbd --- /dev/null +++ b/Data/Agent/signature_utils.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import base64 +from typing import Any, Optional, Sequence + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +_BASE64_HINTS: Sequence[str] = ("base64", "b64", "base-64") + + +def _decode_base64_bytes(value: Optional[str]) -> Optional[bytes]: + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped: + return b"" + cleaned = "".join(stripped.split()) + if not cleaned: + return b"" + try: + return base64.b64decode(cleaned, validate=True) + except Exception: + return None + + +def decode_script_bytes(script_content: Any, encoding_hint: Optional[str]) -> Optional[bytes]: + """ + Normalize a script payload into UTF-8 bytes. + + Returns None only when the payload is unusable (e.g., invalid base64, + unsupported type). Empty content is represented as b"". + """ + if isinstance(script_content, (bytes, bytearray)): + return bytes(script_content) + + if script_content is None: + return b"" + + encoding = str(encoding_hint or "").strip().lower() + if isinstance(script_content, str): + if encoding in _BASE64_HINTS: + decoded = _decode_base64_bytes(script_content) + return decoded + + decoded = _decode_base64_bytes(script_content) + if decoded is not None: + return decoded + + try: + return script_content.encode("utf-8") + except Exception: + return None + + return None + + +def verify_and_store_script_signature( + client: Any, + script_bytes: bytes, + signature_b64: Optional[str], + signing_key_hint: Optional[str] = None, +) -> bool: + """ + Verify a script payload against the provided signature and persist the server + signing key when verification succeeds. + """ + if not isinstance(script_bytes, (bytes, bytearray)): + return False + + signature = _decode_base64_bytes(signature_b64) + if signature is None: + return False + + candidates = [] + if isinstance(signing_key_hint, str): + hint = signing_key_hint.strip() + if hint: + candidates.append(hint) + + stored_key = None + if client is not None and hasattr(client, "load_server_signing_key"): + try: + stored_key_raw = client.load_server_signing_key() + except Exception: + stored_key_raw = None + if isinstance(stored_key_raw, str): + stored_key = stored_key_raw.strip() + if stored_key and stored_key not in candidates: + candidates.append(stored_key) + + payload = bytes(script_bytes) + for key_b64 in candidates: + try: + key_der = base64.b64decode(key_b64, validate=True) + except Exception: + continue + try: + public_key = serialization.load_der_public_key(key_der) + except Exception: + continue + if not isinstance(public_key, ed25519.Ed25519PublicKey): + continue + try: + public_key.verify(signature, payload) + except Exception: + continue + + if client is not None and hasattr(client, "store_server_signing_key"): + try: + if stored_key and stored_key != key_b64: + client.store_server_signing_key(key_b64) + elif not stored_key: + client.store_server_signing_key(key_b64) + except Exception: + pass + return True + + return False diff --git a/Data/Server/job_scheduler.py b/Data/Server/job_scheduler.py index eb72788..9196010 100644 --- a/Data/Server/job_scheduler.py +++ b/Data/Server/job_scheduler.py @@ -322,10 +322,11 @@ def _to_dt_tuple(ts: int) -> Tuple[int, int, int, int, int, int]: class JobScheduler: - def __init__(self, app, socketio, db_path: str): + def __init__(self, app, socketio, db_path: str, script_signer=None): self.app = app self.socketio = socketio self.db_path = db_path + self._script_signer = script_signer self._running = False # Simulated run duration to hold jobs in "Running" before Success self.SIMULATED_RUN_SECONDS = int(os.environ.get("BOREALIS_SIM_RUN_SECONDS", "30")) @@ -545,7 +546,22 @@ class JobScheduler: return None doc = self._load_assembly_document(abs_path, "ansible") content = doc.get("script") or "" - encoded_content = _encode_script_content(content) + normalized_script = (content or "").replace("\r\n", "\n") + script_bytes = normalized_script.encode("utf-8") + encoded_content = base64.b64encode(script_bytes).decode("ascii") if script_bytes or normalized_script == "" else "" + signature_b64: Optional[str] = None + sig_alg: Optional[str] = None + signing_key_b64: Optional[str] = None + if self._script_signer is not None: + try: + signature = self._script_signer.sign(script_bytes) + signature_b64 = base64.b64encode(signature).decode("ascii") + sig_alg = "ed25519" + signing_key_b64 = self._script_signer.public_base64_spki() + except Exception: + signature_b64 = None + sig_alg = None + signing_key_b64 = None variables = doc.get("variables") or [] files = doc.get("files") or [] run_mode_norm = (run_mode or "system").strip().lower() @@ -765,6 +781,12 @@ class JobScheduler: "admin_user": "", "admin_pass": "", } + if signature_b64: + payload["signature"] = signature_b64 + if sig_alg: + payload["sig_alg"] = sig_alg + if signing_key_b64: + payload["signing_key"] = signing_key_b64 try: self.socketio.emit("quick_job_run", payload) except Exception: @@ -1799,9 +1821,9 @@ class JobScheduler: return {} -def register(app, socketio, db_path: str) -> JobScheduler: +def register(app, socketio, db_path: str, script_signer=None) -> JobScheduler: """Factory to create and return a JobScheduler instance.""" - return JobScheduler(app, socketio, db_path) + return JobScheduler(app, socketio, db_path, script_signer=script_signer) def set_online_lookup(scheduler: JobScheduler, fn: Callable[[], List[str]]): diff --git a/Data/Server/server.py b/Data/Server/server.py index ce36947..19f29ac 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -5153,7 +5153,7 @@ ensure_default_admin() # ============================================================================= # Connect the Flask app to the background job scheduler and helpers. -job_scheduler = register_job_scheduler(app, socketio, DB_PATH) +job_scheduler = register_job_scheduler(app, socketio, DB_PATH, script_signer=SCRIPT_SIGNER) scheduler_set_server_runner(job_scheduler, _queue_server_ansible_run) scheduler_set_credential_fetcher(job_scheduler, _fetch_credential_with_secrets) job_scheduler.start() @@ -6922,7 +6922,19 @@ def scripts_quick_run(): env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides) content = _rewrite_powershell_script(content, literal_lookup) - encoded_content = _encode_script_content(content) + normalized_script = (content or "").replace("\r\n", "\n") + script_bytes = normalized_script.encode("utf-8") + encoded_content = base64.b64encode(script_bytes).decode("ascii") if script_bytes or normalized_script == "" else "" + signature_b64 = "" + signing_key_b64 = "" + if SCRIPT_SIGNER: + try: + signature_raw = SCRIPT_SIGNER.sign(script_bytes) + signature_b64 = base64.b64encode(signature_raw).decode("ascii") + signing_key_b64 = SCRIPT_SIGNER.public_base64_spki() + except Exception: + signature_b64 = "" + signing_key_b64 = "" timeout_seconds = 0 try: timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0)) @@ -6975,6 +6987,11 @@ def scripts_quick_run(): "admin_user": admin_user, "admin_pass": admin_pass, } + if signature_b64: + payload["signature"] = signature_b64 + payload["sig_alg"] = "ed25519" + if signing_key_b64: + payload["signing_key"] = signing_key_b64 # Broadcast to all connected clients; no broadcast kw in python-socketio v5 socketio.emit("quick_job_run", payload) try: