413 lines
14 KiB
Python
413 lines
14 KiB
Python
"""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
|