Incorporated Script Code-Signing

This commit is contained in:
2025-10-19 19:55:19 -06:00
parent 7700865bf7
commit 03bb19ef05
6 changed files with 313 additions and 54 deletions

View File

@@ -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

View File

@@ -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 []

View File

@@ -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

View File

@@ -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