Files
archy/scripts/c01_nts.py
T

409 lines
20 KiB
Python

#!/usr/bin/python3 -I
"""Read-only admission of chrony 4.6 NTS observations for the C01 supervisor.
An assessment is not a recovery grant. The protected supervisor must additionally
bind its measured UTC/BOOTTIME mapping, boot/domain and platform qualification to
the authority's fresh challenge. No application-supplied report or saved JSON is
accepted by collect(). Neither this module nor the NTS observer sets host time.
"""
import csv
import hashlib
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation, ROUND_CEILING, ROUND_FLOOR
import io
import ipaddress
import os
from pathlib import Path
import pwd
import re
import selectors
import stat
import subprocess
import time
CHRONYC = Path('/usr/bin/chronyc')
CHRONYD = Path('/usr/sbin/chronyd')
OBSERVER_USER = 'archipelago-nts'
SOCKET = Path('/run/archipelago-nts/chronyd.sock')
PIDFILE = Path('/run/archipelago-nts/chronyd.pid')
CONFIG = Path('/etc/archipelago-nts/chrony.conf')
CA_BUNDLE = Path('/etc/ssl/certs/ca-certificates.crt')
MAX_REPORT = 65536
NANO = Decimal(1_000_000_000)
class Unavailable(ValueError):
pass
@dataclass(frozen=True)
class Policy:
allowed_names: frozenset[str]
# These are observation-quality admission limits, not expiry/drift grace.
max_age_seconds: int = 64
max_clock_error_ns: int = 250_000_000
max_skew_ppm: Decimal = Decimal(100)
@dataclass(frozen=True)
class Assessment:
reference_id: str
reference_address: str
reference_time: Decimal # Last measurement, never a substitute for current UTC.
system_correction: Decimal # chrony's virtual NTP clock minus system clock
clock_error_ns: int
selected: tuple[str, ...]
good_measurements: tuple[tuple[str, int], ...]
@dataclass(frozen=True)
class ClockReading:
before: int
wall: int
after: int
raw: dict | None = None
@dataclass(frozen=True)
class Observation:
assessment: Assessment
boot_id: str
utc_ns: int
boottime_ns: int
sampling_bracket_ns: int
observation_error_ns: int
collected_at_ns: int
raw: dict | None = None
def configuration(policy):
require(policy.allowed_names and len(policy.allowed_names) <= 4, 'invalid NTS source policy')
for name in policy.allowed_names:
require(type(name) is str and len(name) <= 253 and name == name.lower() and
re.fullmatch(r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', name),
'invalid NTS source name')
lines = [f'server {name} nts iburst minpoll 4 maxpoll 4' for name in sorted(policy.allowed_names)]
# No DHCP sources, local-clock fallback, arbitrary offsets, alternate trust
# roots, manual input or certificate-time-check exceptions are accepted.
lines += ['authselectmode require', 'port 0', 'cmdport 0', f'bindcmdaddress {SOCKET}',
f'pidfile {PIDFILE}', 'driftfile /run/archipelago-nts/drift', 'logdir /run/archipelago-nts']
return ('\n'.join(lines) + '\n').encode()
def require(condition, reason):
if not condition:
raise Unavailable(reason)
def rows(raw, columns):
require(type(raw) is str and len(raw.encode()) <= MAX_REPORT and '\x00' not in raw,
'invalid chrony report size/encoding')
try:
result = list(csv.reader(io.StringIO(raw), strict=True))
except csv.Error:
raise Unavailable('malformed chrony CSV report') from None
require(0 < len(result) <= 64 and all(len(row) == columns for row in result),
'unsupported chrony report shape')
return result
def decimal(value):
require(type(value) is str and len(value) <= 48 and
re.fullmatch(r'[+-]?[0-9]+(?:\.[0-9]+)?', value) is not None,
'invalid chrony numeric value')
try:
number = Decimal(value)
except InvalidOperation:
raise Unavailable('invalid chrony numeric value') from None
require(number.is_finite() and abs(number) <= Decimal(2 ** 53), 'chrony value out of range')
return number
def integer(value, maximum=2 ** 64 - 1):
require(type(value) is str and re.fullmatch(r'[0-9]{1,20}', value) is not None,
'invalid chrony integer')
result = int(value)
require(result <= maximum, 'chrony integer out of range')
return result
def address(value):
try:
require('%' not in value, 'scoped NTS address unsupported')
return str(ipaddress.ip_address(value))
except ValueError:
raise Unavailable('invalid NTS address') from None
def indexed(raw, columns, position):
result = {}
for row in rows(raw, columns):
key = address(row[position])
require(key not in result, 'duplicate chrony source')
result[key] = row
return result
def tracking(raw, policy):
values = rows(raw, 14)
require(len(values) == 1, 'ambiguous chrony tracking report')
row = values[0]
require(re.fullmatch(r'[0-9A-F]{8}', row[0]) is not None and
row[0] not in ('00000000', '7F7F0101'), 'local/unknown time reference refused')
reference = address(row[1])
require(1 <= integer(row[2], 16) <= 15 and row[13] == 'Normal', 'time is not qualified/synchronised')
epoch, offset = decimal(row[3]), decimal(row[4])
for value in row[5:13]:
decimal(value)
delay, dispersion, skew = decimal(row[10]), decimal(row[11]), decimal(row[9])
require(epoch > 0 and delay >= 0 and dispersion >= 0 and skew >= 0 and
decimal(row[6]) >= 0 and decimal(row[12]) > 0, 'invalid time quality')
require(skew <= policy.max_skew_ppm, 'time frequency is not yet qualified')
require(abs(decimal(row[7])) <= 100_000 and abs(decimal(row[8])) <= 100_000,
'clock frequency is outside the qualified range')
# chronyc(1): absolute clock error <= abs(system offset) + dispersion + delay/2.
error = int(((abs(offset) + dispersion + delay / 2) * NANO).to_integral_value(rounding=ROUND_CEILING))
require(error <= policy.max_clock_error_ns, 'time error exceeds admission limit')
return row[0], reference, epoch, offset, error
def assess(reports, names, policy):
"""Pure protocol checks for trusted reports; does not authenticate their origin."""
require(policy.allowed_names and all(type(name) is str and name for name in policy.allowed_names), 'missing source policy')
require(type(policy.max_age_seconds) is int and policy.max_age_seconds > 0 and
type(policy.max_clock_error_ns) is int and policy.max_clock_error_ns > 0 and
policy.max_skew_ppm.is_finite() and policy.max_skew_ppm > 0, 'invalid time policy')
require(set(reports) == {'tracking_before', 'sources', 'authdata', 'ntpdata', 'tracking_after'},
'incomplete time observations')
before = tracking(reports['tracking_before'], policy)
after = tracking(reports['tracking_after'], policy)
require(before[:3] == after[:3], 'time reference changed during observation')
sources = indexed(reports['sources'], 10, 2)
auth = indexed(reports['authdata'], 10, 0)
packets = indexed(reports['ntpdata'], 38, 0)
require(sources.keys() == auth.keys() == packets.keys() == names.keys(), 'source set changed during observation')
used = []
primary = []
measurements = []
for ip, source in sources.items():
require(source[0] == '^' and source[1] in ('*', '+', '-', '?', 'x', '~'), 'unsupported time source')
require(names[ip] in policy.allowed_names, 'unapproved time source')
# Even an inactive unauthenticated source could subsequently enter the
# combined estimate. The dedicated observer permits NTS sources only.
require(auth[ip][1] == 'NTS', 'non-NTS source refused')
if source[1] not in ('*', '+'):
continue
used.append(ip)
if source[1] == '*':
primary.append(ip)
require(re.fullmatch(r'[0-7]{1,3}', source[5]) is not None and int(source[5], 8) > 0,
'time source unreachable')
require(integer(source[6]) <= policy.max_age_seconds, 'stale authenticated time')
require(integer(auth[ip][2]) > 0 and
(integer(auth[ip][3]), integer(auth[ip][4])) in {(15, 256), (30, 128)} and
integer(auth[ip][7]) == 0 and 1 <= integer(auth[ip][8]) <= 8 and integer(auth[ip][9]) > 0,
'NTS key establishment/cookies unavailable')
packet = packets[ip]
require(1 <= integer(packet[2], 65535) <= 65535, 'invalid negotiated NTP port')
require(packet[5] == 'Normal' and packet[6] == '4' and packet[7] == 'Server' and
1 <= integer(packet[8], 16) <= 15, 'invalid NTP measurement')
require(packet[23:26] == ['111', '111', '1111'] and packet[27] == 'Yes',
'latest NTP measurement is not authenticated and accepted')
for value in packet[12:15] + packet[17:23]:
decimal(value)
require(all(decimal(packet[index]) >= 0 for index in (12, 13, 14, 19, 20, 21)) and
decimal(packet[17]) > 0, 'invalid NTP timing measurement')
good = integer(packet[33])
require(0 < good <= integer(packet[32]) <= integer(packet[31]) and integer(packet[30]) > 0,
'no good authenticated measurement')
measurements.append((ip, good))
require(primary == [after[1]], 'selected source differs from tracking reference')
require(packets[primary[0]][1] == after[0], 'reference identity mismatch')
return Assessment(after[0], after[1], after[2], after[3], max(before[4], after[4]),
tuple(sorted(used)), tuple(sorted(measurements)))
def protected_path(path, owners, kind):
"""Check the command path/producer namespace, including all ancestry."""
for parent in reversed(path.parents):
meta = parent.lstat()
require(stat.S_ISDIR(meta.st_mode) and meta.st_uid in owners and not meta.st_mode & 0o022,
'unprotected time-service ancestry')
meta = path.lstat()
require(meta.st_uid in owners and not stat.S_ISLNK(meta.st_mode), 'unprotected time-service object')
if kind == 'socket':
# chronyd uses a permissive socket mode inside its private command
# directory. A non-owner cannot reach that socket through a 0700 parent.
parent = path.parent.lstat()
private_parent = parent.st_uid in owners and parent.st_mode & 0o077 == 0
require(stat.S_ISSOCK(meta.st_mode) and meta.st_nlink == 1 and
(not meta.st_mode & 0o007 or private_parent), 'unprotected time-service socket')
else:
require(stat.S_ISREG(meta.st_mode) and meta.st_nlink == 1 and not meta.st_mode & 0o022,
f'unprotected time-service file: {path}')
return meta.st_dev, meta.st_ino, meta.st_uid, meta.st_gid
def query(command, collection_deadline):
require(command in (('tracking',), ('sources',), ('authdata',), ('ntpdata',)) or
len(command) == 2 and command[0] == 'sourcename' and address(command[1]) == command[1],
'unsupported time-service command')
# A single Unix path deliberately disables chronyc's default UDP fallback.
deadline = min(collection_deadline, time.clock_gettime_ns(time.CLOCK_BOOTTIME) + 1_000_000_000)
require(deadline > time.clock_gettime_ns(time.CLOCK_BOOTTIME), 'time collection timed out')
child = subprocess.Popen([str(CHRONYC), '-n', '-c', '-h', str(SOCKET), *command],
env={'PATH': '/usr/bin:/bin', 'LANG': 'C', 'LC_ALL': 'C'}, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True)
output = {child.stdout: bytearray(), child.stderr: bytearray()}
try:
with selectors.DefaultSelector() as poller:
for pipe in output:
os.set_blocking(pipe.fileno(), False)
poller.register(pipe, selectors.EVENT_READ)
while poller.get_map():
remaining = deadline - time.clock_gettime_ns(time.CLOCK_BOOTTIME)
require(remaining > 0, 'time-service query timed out')
for key, _ in poller.select(min(remaining / 1_000_000_000, 0.05)):
part = os.read(key.fileobj.fileno(), 8192)
if not part:
poller.unregister(key.fileobj)
else:
output[key.fileobj].extend(part)
require(sum(map(len, output.values())) <= MAX_REPORT, 'oversized time-service response')
remaining = deadline - time.clock_gettime_ns(time.CLOCK_BOOTTIME)
require(remaining > 0 and child.wait(timeout=remaining / 1_000_000_000) == 0,
'time-service query refused')
require(not output[child.stderr], 'time-service query diagnostic')
return output[child.stdout].decode('utf-8', errors='strict')
finally:
if child.poll() is None:
child.kill(); child.wait()
child.stdout.close(); child.stderr.close()
def daemon_identity(owners, group):
protected_path(PIDFILE, owners, 'file')
raw = PIDFILE.read_bytes()
require(len(raw) <= 32, 'invalid time-service PID file')
pid = integer(raw.decode('ascii').strip(), 2 ** 31 - 1)
require(pid > 1, 'invalid time-service PID')
process = Path('/proc') / str(pid)
require((process / 'exe').resolve(strict=True) == CHRONYD, 'wrong time-service executable')
status = dict(line.split(':', 1) for line in (process / 'status').read_text().splitlines() if ':' in line)
require(set(map(int, status['Uid'].split())) == owners - {0}, 'wrong time-service credentials')
require(set(map(int, status['Gid'].split())) == {group} and
set(map(int, status['Groups'].split())) <= {group}, 'unexpected time-service group access')
require(status['NoNewPrivs'].strip() == '1' and
all(int(status[field].strip(), 16) & (1 << 25) == 0 for field in ('CapEff', 'CapPrm', 'CapAmb')),
'time observer retains host-clock control or elevation')
args = (process / 'cmdline').read_bytes().rstrip(b'\x00').split(b'\x00')
require(args == [bytes(CHRONYD), b'-x', b'-U', b'-n', b'-u', OBSERVER_USER.encode(), b'-f', bytes(CONFIG)],
'unexpected time-service invocation')
environment = (process / 'environ').read_bytes()
require(len(environment) <= MAX_REPORT, 'oversized time-service environment')
keys = [entry.split(b'=', 1)[0] for entry in environment.split(b'\x00') if entry]
require(not any(key.startswith((b'LD_', b'DYLD_', b'GNUTLS_')) or
key in (b'SSL_CERT_FILE', b'SSL_CERT_DIR', b'SSLKEYLOGFILE', b'NSS_SSLKEYLOGFILE')
for key in keys), 'unqualified time-service environment')
raw_stat = (process / 'stat').read_text()
return pid, int(raw_stat[raw_stat.rindex(')') + 2:].split()[19])
def platform_identity():
def text(path, limit):
with Path(path).open('rb') as stream:
data = stream.read(limit + 1)
require(0 < len(data) <= limit, 'clock identity input missing/oversized')
return data.decode('utf-8').strip()
release = text('/proc/sys/kernel/osrelease', 256)
version = text('/proc/sys/kernel/version', 1024)
source = text('/sys/devices/system/clocksource/clocksource0/current_clocksource', 128)
hardware = hashlib.sha256(b'C01 hardware v1\0')
for name in ('sys_vendor', 'product_name', 'board_name', 'bios_version', 'bios_date'):
value = text('/sys/class/dmi/id/' + name, 1024)
hardware.update(name.encode() + b'\0' + value.encode() + b'\0')
keys = ('vendor_id', 'cpu family', 'model', 'model name', 'stepping', 'microcode')
rows = set()
for line in text('/proc/cpuinfo', 262144).splitlines():
if ':' in line:
key, value = line.split(':', 1)
if key.strip() in keys:
rows.add(key.strip() + '=' + value.strip())
require(all(any(row.startswith(key + '=') for row in rows) for key in keys), 'clock CPU identity incomplete')
for row in sorted(rows):
hardware.update(row.encode() + b'\0')
return dict(kernel=list(hashlib.sha256((release + '\0' + version).encode()).digest()),
clocksource=list(hashlib.sha256(source.encode()).digest()), hardware=list(hardware.digest()))
def clock_reading(raw=False):
identity = platform_identity() if raw else None
raw_before = time.clock_gettime_ns(time.CLOCK_MONOTONIC_RAW) if raw else None
before = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
wall = time.time_ns()
after = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
raw_after = time.clock_gettime_ns(time.CLOCK_MONOTONIC_RAW) if raw else None
# Identity is checked on both sides of the actual clock witness, not inside
# its RAW/BOOTTIME bracket. Filesystem/CPU inventory latency is not clock
# adjustment and must not manufacture an unnecessarily wide RAW anchor.
if raw:
require(platform_identity() == identity, 'clock identity changed during collection')
def duration(n): return dict(secs=n // 1_000_000_000, nanos=n % 1_000_000_000)
witness = dict(before=duration(raw_before), after=duration(raw_after), identity=identity) if raw else None
return ClockReading(before, wall, after, witness)
def bind_observation(assessment, start, end, boot_before, boot_after, policy):
require(re.fullmatch(r'[0-9a-f]{32}', boot_before) is not None and
boot_before != '0' * 32 and boot_before == boot_after, 'time-observation boot changed')
require(0 <= start.before <= start.after <= end.before <= end.after,
'time-observation monotonic regression')
# CLOCK_REALTIME and CLOCK_BOOTTIME share the Linux adjusted timebase.
# Their boot-epoch intervals must overlap; a wall-clock step is not silently
# incorporated into an authenticated observation.
require(max(start.wall - start.after, end.wall - end.after) <=
min(start.wall - start.before, end.wall - end.before), 'wall clock stepped during time observation')
span = end.after - start.before
error = assessment.clock_error_ns + span
require(error <= policy.max_clock_error_ns, 'time collection exceeds the qualified error budget')
# Use the current kernel reading corrected against the authenticated virtual
# NTP clock. Ref time is deliberately NOT used as the current time.
correction = int((assessment.system_correction * NANO).to_integral_value(rounding=ROUND_CEILING))
utc = end.wall + correction
require(utc > 0, 'invalid authenticated UTC observation')
reference = int((assessment.reference_time * NANO).to_integral_value(rounding=ROUND_FLOOR))
require(0 <= utc - reference <= policy.max_age_seconds * 1_000_000_000,
'stale or future tracking reference')
return Observation(assessment, boot_before, utc, end.before, end.after - end.before, error, end.after, end.raw)
def collect(policy, raw=False):
"""Live root-only collection. No cached reports or caller-selected paths."""
require(os.geteuid() == 0, 'protected time collection requires the supervisor')
account = pwd.getpwnam(OBSERVER_USER)
chrony_uid = account.pw_uid
require(chrony_uid != 0 and account.pw_gid != 0, 'invalid dedicated chrony principal')
owners = {0, chrony_uid}
protected_path(CHRONYC, {0}, 'file')
protected_path(CHRONYD, {0}, 'file')
protected_path(CA_BUNDLE, {0}, 'file')
protected_path(CONFIG, {0}, 'file')
expected_config = configuration(policy)
require(CONFIG.read_bytes() == expected_config, 'time-service configuration differs from policy')
socket_identity = protected_path(SOCKET, owners, 'socket')
producer = daemon_identity(owners, account.pw_gid)
boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip().replace('-', '')
start = clock_reading(raw=raw)
deadline = time.clock_gettime_ns(time.CLOCK_BOOTTIME) + 2_000_000_000
reports = {'tracking_before': query(('tracking',), deadline), 'sources': query(('sources',), deadline),
'authdata': query(('authdata',), deadline), 'ntpdata': query(('ntpdata',), deadline)}
names = {ip: query(('sourcename', ip), deadline).strip() for ip in indexed(reports['sources'], 10, 2)}
reports['tracking_after'] = query(('tracking',), deadline)
end = clock_reading(raw=raw)
require(socket_identity == protected_path(SOCKET, owners, 'socket') and producer == daemon_identity(owners, account.pw_gid),
'time service changed during observation')
require(CONFIG.read_bytes() == expected_config, 'time-service configuration changed during observation')
require(time.clock_gettime_ns(time.CLOCK_BOOTTIME) < deadline, 'time collection timed out')
return bind_observation(assess(reports, names, policy), start, end, boot,
Path('/proc/sys/kernel/random/boot_id').read_text().strip().replace('-', ''), policy)