"""Freeze public chrony dependencies and narrow IPv4 egress for the PID1 realm.""" import hashlib import io import ipaddress import json import os from pathlib import Path import socket import subprocess import sys import tarfile out=Path(sys.argv[1]) image='d75bb2137bd95cae072ebdd83c4e53e7a50366b5fc7ecd986b700860f42e09c2' name='qualification-c01-nts-deps-'+out.name env={**os.environ,'CONTAINERS_CONF_OVERRIDE':str(out/'containers.conf')} base=['podman','run','--rm','--pull=never','--network=none','--read-only','--cap-drop=ALL', '--security-opt=no-new-privileges','--cpus=2','--memory=512m','--memory-swap=512m', '--http-proxy=false','--entrypoint=/usr/bin/ldd',image] listing=subprocess.check_output([*base,'/usr/sbin/chronyd','/usr/bin/chronyc'],env=env,text=True) paths={'/usr/sbin/chronyd','/usr/bin/chronyc'} | {w for w in listing.split() if w.startswith('/') and not w.endswith(':')} ordered=sorted(paths) resolved=subprocess.check_output([*base[:-2],'--entrypoint=/usr/bin/readlink',image,'-f',*ordered],env=env,text=True).splitlines() assert len(resolved)==len(ordered) targets=dict(zip(ordered,resolved)) subprocess.run(['podman','create','--pull=never','--name',name,'--network=none', '--cap-drop=ALL','--entrypoint=/bin/false',image],env=env,check=True,stdout=subprocess.DEVNULL) files={};manifest={} for path in sorted(paths): assert '..' not in Path(path).parts raw=subprocess.check_output(['podman','cp',name+':'+targets[path],'-'],env=env) with tarfile.open(fileobj=io.BytesIO(raw)) as archive: members=[m for m in archive.getmembers() if m.isfile()] assert len(members)==1 and members[0].uid==0 and not members[0].mode&0o022 data=archive.extractfile(members[0]).read() assert data.startswith(b'\x7fELF') files[path]=data manifest[path]={'source_image':image,'source_image_path':targets[path],'sha256':hashlib.sha256(data).hexdigest()} # One pinned public shared-library closure for nft and the required Tor owner # tool. Never overlay a second libcap/loader variant after NTS verification. # No host configurations, keys or services are copied. host_paths=set() for program in ('/usr/sbin/nft','/usr/bin/tor'): host_paths.add(program) host_paths.update(w for w in subprocess.check_output(['ldd',program],text=True).split() if w.startswith('/')) for path in host_paths: source=Path(path).resolve(strict=True) for entry in (source,*source.parents): meta=entry.stat();assert meta.st_uid==0 and not meta.st_mode&0o022 files[path]=source.read_bytes() manifest[path]={'source':str(source),'sha256':hashlib.sha256(files[path]).hexdigest()} with tarfile.open(out/'chrony-runtime.tar','x') as archive: for path,data in sorted(files.items()): item=tarfile.TarInfo(path.lstrip('/'));item.size=len(data);item.mode=0o755 archive.addfile(item,io.BytesIO(data)) addresses={name:sorted({str(ipaddress.IPv4Address(row[4][0])) for row in socket.getaddrinfo(name,4460,family=socket.AF_INET,type=socket.SOCK_STREAM)}) for name in ('time.cloudflare.com','nts.netnod.se')} rules=['table inet c01_nts {', 'chain input { type filter hook input priority 0; policy drop; iifname "lo" accept; ct state established,related accept; }', 'chain forward { type filter hook forward priority 0; policy drop; }', 'chain output { type filter hook output priority 0; policy drop; oifname "lo" accept; ct state established,related accept;'] for address in sorted(set(sum(addresses.values(),[]))): rules += [f'ip daddr {address} tcp dport 4460 accept;',f'ip daddr {address} udp dport 123 accept;'] for address in ('1.1.1.1','1.0.0.1'): rules += [f'ip daddr {address} udp dport 53 accept;',f'ip daddr {address} tcp dport 53 accept;'] rules += ['}','}'] (out/'nts-egress.nft').write_text('\n'.join(rules)+'\n') (out/'nts-addresses.json').write_text(json.dumps(addresses,indent=2)+'\n') (out/'chrony-artifact.json').write_text(json.dumps(manifest,indent=2,sort_keys=True)+'\n')