railiance-clock/src/railiance_clock/authority.py
tegwick c976c9c277 Harden authority sampling and health failures; prepare operations handoff
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e6f0-a6f6-7b93-9124-55ef584930e2
2026-09-28 10:08:52 +02:00

142 lines
6.8 KiB
Python

"""Unprivileged authority; health export is supplied by the host owner."""
import json
import os
from pathlib import Path
import stat
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from .core import ClockError, bounded
from .protocol import strict_json, validate, sign
class Authority:
def __init__(self, config, *, wall=time.time_ns, monotonic=time.monotonic_ns):
self.config = config
self.epoch = str(uuid.uuid4())
self.wall, self.monotonic = wall, monotonic
fd = os.open(config['private_key_file'], os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600:
raise ClockError('private authority key ownership or mode refused')
self.key = serialization.load_pem_private_key(stream.read(8193), password=None)
if not isinstance(self.key, ec.EllipticCurvePrivateKey) or not isinstance(self.key.curve, ec.SECP256R1):
raise ClockError('P-256 authority key required')
self.boot_id = Path('/proc/sys/kernel/random/boot_id').read_text().strip()
self.start_wall, self.start_mono = wall(), monotonic()
self.failed = False
self.lock = threading.Lock()
def health(self, *, wall_ns=None, monotonic_ns=None):
path = Path(self.config['health_file'])
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != self.config.get('health_owner_uid', 0) or info.st_mode & 0o022:
raise ClockError('untrusted host health file')
health = strict_json(stream.read(8193))
if not isinstance(health, dict):
raise ClockError('invalid host health object')
if health.get('quality') != 'bounded' or health.get('boot_id') != self.boot_id or health.get('policy_id') != self.config['policy_id']:
raise ClockError('host quality unusable')
observed = bounded(health.get('observed_monotonic_ns'))
reported_error = bounded(health.get('error_ns'))
if wall_ns is None:
wall_ns = self.wall()
if monotonic_ns is None:
monotonic_ns = self.monotonic()
age = monotonic_ns - observed
if not 0 <= age < self.config['max_health_age_ns']:
raise ClockError('host health stale')
error = bounded(reported_error + (age * self.config['host_drift_ppm'] + 999999) // 1000000)
if error > self.config['max_error_ns']:
raise ClockError('host error bound exceeded')
if abs((wall_ns - self.start_wall) - (monotonic_ns - self.start_mono)) > self.config['max_continuity_error_ns']:
self.failed = True
if self.failed:
raise ClockError('authority continuity lost; new epoch admission required')
return error
def sample(self, raw):
request = validate('time-request', strict_json(raw, 1024))
for key in ('authority_id','environment'):
if request[key] != self.config[key]:
raise ClockError('wrong requested authority')
with self.lock:
received = self.wall()
error = self.health()
sent = self.wall()
sent_mono = self.monotonic()
if sent < received:
self.failed = True
raise ClockError('authority clock moved backwards')
# Validate the exact timestamp being signed, including health aging
# during sample preparation. A later wall read could hide a transient step.
error = max(error, self.health(wall_ns=sent, monotonic_ns=sent_mono))
payload = dict(request, epoch=self.epoch, policy_id=self.config['policy_id'],
receive_unix_ns=str(received), send_unix_ns=str(sent), error_ns=str(error),
max_age_ns=str(self.config['max_sample_age_ns']), quality='bounded')
return sign(payload, self.key, self.config['kid'])
class Server(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 8
def __init__(self, address, authority):
self.authority = authority
self.slots = threading.BoundedSemaphore(8)
super().__init__(address, Handler)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try: super().process_request_thread(request, client_address)
finally: self.slots.release()
class Handler(BaseHTTPRequestHandler):
def setup(self):
self.request.settimeout(3)
super().setup()
def log_message(self, *_): pass
def reply(self, status, body, content_type='application/json'):
self.send_response(status)
self.send_header('Content-Type', content_type)
self.send_header('Cache-Control', 'no-store')
self.send_header('Content-Length', str(len(body)))
self.send_header('Connection', 'close')
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == '/healthz':
self.reply(200, json.dumps({'status':'alive','epoch':self.server.authority.epoch}).encode())
elif self.path == '/readyz':
try:
self.server.authority.health()
self.reply(200, b'{"status":"usable"}')
except (ClockError, OSError, ValueError, KeyError):
self.reply(503, b'{"status":"unusable"}')
else: self.reply(404, b'{}')
def do_POST(self):
if self.path != '/v1/time-samples':
self.reply(404, b'{}'); return
try:
lengths = self.headers.get_all('Content-Length', [])
if len(lengths) != 1 or self.headers.get('Transfer-Encoding') or self.headers.get('Content-Encoding'):
raise ClockError('ambiguous body framing')
size = int(lengths[0])
if not 0 < size <= 1024 or self.headers.get_content_type() != 'application/json':
raise ClockError('invalid request body')
raw = self.rfile.read(size)
if len(raw) != size: raise ClockError('truncated request')
body = self.server.authority.sample(raw)
self.reply(200, body, 'application/jose')
except (ClockError, OSError, ValueError, KeyError):
self.reply(503, b'{"status":"unusable"}')