railiance-clock/src/railiance_clock/authority.py
tegwick 9892c81ed6 Implement authenticated Railiance application clock
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
2026-09-15 22:16:23 +02:00

130 lines
6.2 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):
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 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')
age = self.monotonic() - health['observed_monotonic_ns']
if not 0 <= age < self.config['max_health_age_ns']:
raise ClockError('host health stale')
error = bounded(health['error_ns'] + (age * self.config['host_drift_ppm'] + 999999) // 1000000)
if error > self.config['max_error_ns']:
raise ClockError('host error bound exceeded')
if abs((self.wall() - self.start_wall) - (self.monotonic() - 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()
if sent < received:
self.failed = True
raise ClockError('authority clock moved backwards')
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"}')