"""Durable fixed-path evidence for privileged runtime operations.""" import hashlib import json import os from pathlib import Path import stat from host_agent_protocol import decode_request_payload, encode_request_payload from runtime_security import fsync_directory, reject_reparse_components HOST_ROOT_UID = 0 HOST_ROOT_GID = 0 HOST_RUNTIME_GID = 10001 HOST_STATE_ROOT = Path('/var/lib/truf/host-agent') HOST_STATE_ROOT_MODE = 0o700 HOST_OPERATION_DIRECTORY = HOST_STATE_ROOT / 'operations' HOST_OPERATION_DIRECTORY_MODE = 0o700 HOST_RESULT_DIRECTORY = HOST_STATE_ROOT / 'results' HOST_RESULT_DIRECTORY_MODE = 0o750 HOST_FAILED_HOLD_PATH = HOST_STATE_ROOT / 'failed-hold.json' MAX_STATE_BYTES = 16 * 1024 _PHASES = { 'prepared', 'forward_started', 'rollback_started', 'succeeded', 'failed', 'rolled_back', 'failed_hold', } _PUBLICATION_STATES = {'original', 'partial', 'candidate'} _TERMINAL_RESULTS = {'succeeded', 'failed', 'rolled_back', 'failed_hold'} _TRANSITIONS = { 'prepared': {'forward_started', 'rollback_started', 'failed'}, 'forward_started': {'rollback_started', 'succeeded'}, 'rollback_started': {'rolled_back', 'failed_hold'}, } class HostStateError(RuntimeError): def __init__(self, category, *, cancellation=None): self.category = str(category) self.cancellation = cancellation super().__init__('host runtime state failed') def _canonical(value): try: payload = json.dumps( value, sort_keys=True, separators=(',', ':'), ensure_ascii=True, allow_nan=False, ).encode('ascii') except (TypeError, ValueError): raise HostStateError('evidence') from None if not payload or len(payload) > MAX_STATE_BYTES: raise HostStateError('evidence') return payload def _require_directory(path, *, gid, mode): try: reject_reparse_components(path) details = os.stat(path, follow_symlinks=False) if not stat.S_ISDIR(details.st_mode): raise OSError('not a directory') if os.name != 'nt' and ( details.st_uid != HOST_ROOT_UID or details.st_gid != gid or stat.S_IMODE(details.st_mode) != mode ): raise OSError('directory metadata') except Exception: raise HostStateError('filesystem') from None def _read_file(path, *, gid, mode): descriptor = None try: reject_reparse_components(Path(path).parent) flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) if hasattr(os, 'O_BINARY'): flags |= os.O_BINARY if hasattr(os, 'O_NOFOLLOW'): flags |= os.O_NOFOLLOW descriptor = os.open(path, flags) before = os.fstat(descriptor) if ( not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or ( os.name != 'nt' and ( before.st_uid != HOST_ROOT_UID or before.st_gid != gid or stat.S_IMODE(before.st_mode) != mode ) ) ): raise OSError('file metadata') with os.fdopen(descriptor, 'rb') as handle: descriptor = None payload = handle.read(MAX_STATE_BYTES + 1) after = os.fstat(handle.fileno()) current = os.stat(path, follow_symlinks=False) identity = lambda item: ( item.st_dev, item.st_ino, item.st_size, getattr(item, 'st_mtime_ns', None), None if os.name == 'nt' else getattr(item, 'st_ctime_ns', None), ) if ( identity(before) != identity(after) or identity(after) != identity(current) or len(payload) > MAX_STATE_BYTES ): raise OSError('file changed') return payload except FileNotFoundError: raise except Exception: raise HostStateError('filesystem') from None finally: if descriptor is not None: os.close(descriptor) def _decode_canonical(payload): try: value = json.loads(payload.decode('ascii')) except (UnicodeDecodeError, json.JSONDecodeError): raise HostStateError('evidence') from None if not isinstance(value, dict) or _canonical(value) != payload: raise HostStateError('evidence') return value def _write_stage(path, payload, *, gid, mode): stage = Path(path).parent / f'.{Path(path).name}.stage' descriptor = None created = False published = False try: try: details = os.stat(stage, follow_symlinks=False) if ( not stat.S_ISREG(details.st_mode) or details.st_nlink != 1 or (os.name != 'nt' and details.st_uid != HOST_ROOT_UID) ): raise OSError('unsafe stage') os.unlink(stage) fsync_directory(stage.parent) except FileNotFoundError: pass flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_CLOEXEC', 0) if hasattr(os, 'O_BINARY'): flags |= os.O_BINARY if hasattr(os, 'O_NOFOLLOW'): flags |= os.O_NOFOLLOW descriptor = os.open(stage, flags, mode) created = True if os.name != 'nt': os.fchmod(descriptor, mode) details = os.fstat(descriptor) if details.st_uid != HOST_ROOT_UID or details.st_gid != gid: os.fchown(descriptor, HOST_ROOT_UID, gid) view = memoryview(payload) written = 0 while written < len(view): count = os.write(descriptor, view[written:]) if count <= 0: raise OSError('short write') written += count os.fsync(descriptor) os.close(descriptor) descriptor = None os.replace(stage, path) created = False published = True fsync_directory(Path(path).parent) stored = _read_file(path, gid=gid, mode=mode) if not hashlib.sha256(stored).digest() == hashlib.sha256(payload).digest(): raise HostStateError('evidence') except HostStateError: if published: raise HostStateError('uncertain') from None raise except BaseException as error: if published: cancellation = error if not isinstance(error, Exception) else None raise HostStateError( 'uncertain', cancellation=cancellation, ) from None if not isinstance(error, Exception): raise raise HostStateError('filesystem') from None finally: if descriptor is not None: os.close(descriptor) if created: try: os.unlink(stage) fsync_directory(stage.parent) except OSError: pass def _publish_exact(path, payload, *, gid, mode): try: existing = _read_file(path, gid=gid, mode=mode) except FileNotFoundError: _write_stage(path, payload, gid=gid, mode=mode) return payload if existing != payload: raise HostStateError('conflict') return existing def failed_hold_operation(): try: payload = _read_file( HOST_FAILED_HOLD_PATH, gid=HOST_ROOT_GID, mode=0o600, ) except FileNotFoundError: return None value = _decode_canonical(payload) if ( set(value) != { 'schema', 'operation_id', 'action', 'forward_category', 'publication_state', 'containment_confirmed', } or value.get('schema') != 1 or not isinstance(value.get('operation_id'), str) or value.get('publication_state') not in _PUBLICATION_STATES or type(value.get('containment_confirmed')) is not bool or not isinstance(value.get('forward_category'), str) ): raise HostStateError('evidence') return value['operation_id'] class HostOperationState: def __init__(self, request): self.request = decode_request_payload(encode_request_payload(request)) self.operation_path = ( HOST_OPERATION_DIRECTORY / f'{self.request.operation_id}.json' ) self.result_path = HOST_RESULT_DIRECTORY / f'{self.request.operation_id}.json' def _phase_record( self, phase, publication_state, *, forward_category=None, safe_detail=None, containment_confirmed=None, ): if ( phase not in _PHASES or publication_state not in _PUBLICATION_STATES or forward_category is not None and not isinstance(forward_category, str) or safe_detail is not None and not isinstance(safe_detail, str) or containment_confirmed is not None and type(containment_confirmed) is not bool ): raise HostStateError('evidence') return { 'schema': 1, 'operation_id': self.request.operation_id, 'action': self.request.action.value, 'active_config_sha256': self.request.active_config_sha256, 'active_secrets_sha256': self.request.active_secrets_sha256, 'candidate_config_sha256': self.request.candidate_config_sha256, 'candidate_secrets_sha256': self.request.candidate_secrets_sha256, 'phase': phase, 'publication_state': publication_state, 'forward_category': forward_category, 'safe_detail': safe_detail, 'containment_confirmed': containment_confirmed, } def _read_phase(self): payload = _read_file(self.operation_path, gid=HOST_ROOT_GID, mode=0o600) value = _decode_canonical(payload) if set(value) != set(self._phase_record('prepared', 'original')): raise HostStateError('evidence') expected = self._phase_record( value.get('phase'), value.get('publication_state'), forward_category=value.get('forward_category'), safe_detail=value.get('safe_detail'), containment_confirmed=value.get('containment_confirmed'), ) if value != expected: raise HostStateError('evidence') return value def initialize(self, publication_state='original'): _require_directory( HOST_STATE_ROOT, gid=HOST_ROOT_GID, mode=HOST_STATE_ROOT_MODE, ) _require_directory( HOST_OPERATION_DIRECTORY, gid=HOST_ROOT_GID, mode=HOST_OPERATION_DIRECTORY_MODE, ) hold = failed_hold_operation() if hold is not None and hold != self.request.operation_id: raise HostStateError('failed_hold') try: return self._read_phase() except FileNotFoundError: value = self._phase_record('prepared', publication_state) _write_stage( self.operation_path, _canonical(value), gid=HOST_ROOT_GID, mode=0o600, ) return value def advance( self, expected_phase, next_phase, publication_state, *, forward_category=None, safe_detail=None, containment_confirmed=None, ): current = self._read_phase() value = self._phase_record( next_phase, publication_state, forward_category=forward_category, safe_detail=safe_detail, containment_confirmed=containment_confirmed, ) if current == value: return value if ( current['phase'] != expected_phase or next_phase not in _TRANSITIONS.get(expected_phase, set()) ): raise HostStateError('state') _write_stage( self.operation_path, _canonical(value), gid=HOST_ROOT_GID, mode=0o600, ) return value def terminal_result(self): try: payload = _read_file( self.result_path, gid=HOST_RUNTIME_GID, mode=0o640, ) except FileNotFoundError: return None value = _decode_canonical(payload) if ( set(value) != { 'schema', 'operation_id', 'action', 'result', 'safe_category', 'safe_detail', 'resulting_identity', } or value.get('schema') != 1 or value.get('operation_id') != self.request.operation_id or value.get('action') != self.request.action.value or value.get('result') not in _TERMINAL_RESULTS ): raise HostStateError('evidence') return value def publish_result( self, result, *, safe_category, safe_detail, resulting_identity, ): if result not in _TERMINAL_RESULTS: raise HostStateError('evidence') if result == 'succeeded': if safe_category is not None or safe_detail is not None: raise HostStateError('evidence') elif not isinstance(safe_category, str) or not isinstance(safe_detail, str): raise HostStateError('evidence') if resulting_identity is not None and ( not isinstance(resulting_identity, dict) or set(resulting_identity) != { 'active_config_sha256', 'active_secrets_sha256', } or any( not isinstance(value, str) or len(value) != 64 for value in resulting_identity.values() ) ): raise HostStateError('evidence') value = { 'schema': 1, 'operation_id': self.request.operation_id, 'action': self.request.action.value, 'result': result, 'safe_category': safe_category, 'safe_detail': safe_detail, 'resulting_identity': resulting_identity, } _require_directory( HOST_RESULT_DIRECTORY, gid=HOST_RUNTIME_GID, mode=HOST_RESULT_DIRECTORY_MODE, ) _publish_exact( self.result_path, _canonical(value), gid=HOST_RUNTIME_GID, mode=0o640, ) return value def publish_failed_hold( self, *, forward_category, publication_state, containment_confirmed, ): marker = { 'schema': 1, 'operation_id': self.request.operation_id, 'action': self.request.action.value, 'forward_category': str(forward_category), 'publication_state': publication_state, 'containment_confirmed': bool(containment_confirmed), } _publish_exact( HOST_FAILED_HOLD_PATH, _canonical(marker), gid=HOST_ROOT_GID, mode=0o600, ) return marker