Files
archy/scripts/c01_admission.py

285 lines
16 KiB
Python

"""Conditional native admission: measured protected objects and live lifetimes.
Deployment supplies hashes/dispositions, never a ready flag. A supervised root
checker in PID1's network namespace holds executable/asset/pidfd capabilities and
rechecks them. Its loss fences the application through the normal supervisor.
"""
import ctypes
import hashlib
import json
import os
from pathlib import Path
import select
import resource
import socket
import stat
import struct
import subprocess
import time
import c01_network
import c01_network_owner
POLICIES=Path('/etc/archipelago-c01/admission')
ROLES=frozenset(('application','authority','collector','host-io','network-settings','tor-owner','admission'))
ASSETS={
'systemctl':'/usr/bin/systemctl','wg':'/usr/bin/wg',
'journalctl':'/usr/bin/journalctl','podman':'/usr/bin/podman',
'tor_tool':'/usr/bin/tor',
'tor_config':'/etc/tor/torrc',
'filebrowser_helper':'/opt/archipelago/scripts/filebrowser-credentials.py',
'indeehub_helper':'/opt/archipelago/scripts/indeehub-maintenance-controller.py',
'fips_plan':'/etc/archipelago-fips-wg/plan.json',
'frontend':'/opt/archipelago/web-ui/index.html',
'catalog':'/opt/archipelago/web-ui/catalog.json',
'frontend_manifest':'/etc/archipelago-c01/frontend.json',
'containment':'/opt/archipelago/web-ui/archipelago-runtime/image-recipe/configs/nginx-archipelago.conf',
}
UNITS={'fips':'archipelago-fips.service','wg':'archipelago-wg.service',
'tor':'tor@default.service','network_mutations':'NetworkManager.service'}
SERVICE_ASSETS={
'fips':{'fips_unit':'/etc/systemd/system/archipelago-fips.service','fips_program':'/usr/bin/fips'},
'wg':{'wg_unit':'/etc/systemd/system/archipelago-wg.service'},
'tor':{'tor_unit':'/usr/lib/systemd/system/tor@default.service','tor_program':'/usr/bin/tor'},
'network_mutations':{'nm_unit':'/usr/lib/systemd/system/NetworkManager.service','nm_program':'/usr/sbin/NetworkManager'},
}
FIPS_ASSETS={name:'/etc/fips/'+name for name in ('fips.yaml','fips.key','fips.pub','fips.nft')}
FIPS_ASSETS.update({name:'/etc/fips/fips.d/'+name for name in ('80-web-ui.nft','85-app-ports.nft','90-ssh.nft')})
FIPS_ASSETS['wg.public']='/etc/archipelago-fips-wg/wg.public'
def require(ok,reason):
if not ok:raise ValueError('native admission: '+reason)
def read_ready(descriptor, timeout=0):
# The complete frontend may pin up to 2048 files before pidfds are opened.
# poll has no select FD_SETSIZE ceiling and reports HUP/ERR/NVAL as ready,
# preserving fail-closed lifetime and control-channel revocation checks.
poller=select.poll()
poller.register(descriptor,select.POLLIN)
return bool(poller.poll(int(timeout*1000)))
def prepare_descriptor_budget():
# Accommodate the declared 2048-asset closure plus policy/process handles.
# Change only this checker's soft limit, within its existing hard allowance;
# never lower an already larger limit or change the administrator's ceiling.
soft,hard=resource.getrlimit(resource.RLIMIT_NOFILE)
if soft!=resource.RLIM_INFINITY and soft<4096:
require(hard==resource.RLIM_INFINITY or hard>=4096,'descriptor budget below supported asset closure')
resource.setrlimit(resource.RLIMIT_NOFILE,(4096,hard))
def identity(meta):return (meta.st_dev,meta.st_ino,meta.st_size,meta.st_mtime_ns,meta.st_ctime_ns,meta.st_mode,meta.st_uid,meta.st_gid)
def require_public_identity(raw, expected):
"""Match the appointed FIPS identity in the installed raw/hex/NIP-19 formats.
Encoding the known expected key avoids accepting a decoded foreign key or
treating an installed independent FIPS identity as the Nostr node identity.
The native owner separately verifies secret/public consistency.
"""
require(type(expected) is bytes and len(expected)==32 and any(expected),'FIPS public appointment')
if raw==expected:return
try:text=raw.decode('ascii').strip()
except UnicodeDecodeError:raise ValueError('native admission: FIPS public encoding') from None
alphabet='qpzry9x8gf2tvdw0s3jn54khce6mua7l'
accumulator=0;bits=0;data=[]
for byte in expected:
accumulator=(accumulator<<8)|byte;bits+=8
while bits>=5:
bits-=5;data.append((accumulator>>bits)&31)
accumulator &= (1<<bits)-1
if bits:data.append((accumulator<<(5-bits))&31)
prefix='npub';checksum=1
values=[ord(c)>>5 for c in prefix]+[0]+[ord(c)&31 for c in prefix]+data+[0]*6
for value in values:
high=checksum>>25;checksum=((checksum&0x1ffffff)<<5)^value
for n,generator in enumerate((0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3)):
if (high>>n)&1:checksum ^= generator
checksum ^= 1
encoded=prefix+'1'+''.join(alphabet[n] for n in data+[(checksum>>(5*(5-i)))&31 for i in range(6)])
require(text in (expected.hex(),expected.hex().upper(),encoded,encoded.upper()),'FIPS public identity differs from appointment')
def open_asset(path,expected):
path=Path(path)
for part in path.parents:
m=part.lstat();require(stat.S_ISDIR(m.st_mode) and m.st_uid==0 and not m.st_mode&0o022,'asset ancestry')
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
try:
m=os.fstat(fd)
require(stat.S_ISREG(m.st_mode) and m.st_uid==0 and m.st_nlink==1 and not m.st_mode&0o6022,'asset custody')
with os.fdopen(os.dup(fd),'rb') as stream:
require(hashlib.file_digest(stream,'sha256').hexdigest()==expected,'asset digest '+str(path))
require(identity(os.fstat(fd))==identity(m),'asset changed during measurement')
return fd,identity(m)
except BaseException:os.close(fd);raise
def validate(value,launch):
require(type(value) is dict,'policy fields')
version=value.get('version')
require(type(version) is int and version in (1,2),'policy version')
fields={'version','platform_sha256','roles','assets','services','optional_unavailable','required_routes'}
if version==2:fields.update(('fips_public_key','service_argv_sha256'))
require(set(value)==fields,'policy fields')
if version==2:
public=value['fips_public_key']
require(type(public) is str and len(public)==64 and public!='0'*64 and
all(c in '0123456789abcdef' for c in public),'FIPS public appointment')
require(value['platform_sha256']==launch['platform_profile_sha256'],'candidate platform binding')
require(type(value['roles']) is list and len(value['roles'])==len(ROLES) and set(value['roles'])==ROLES,'required roles')
require(type(value['services']) is dict and set(value['services'])==set(UNITS),'service dispositions')
expected=set(ASSETS)
for name,state in value['services'].items():
if state!='uninstalled':expected.update(k for k in SERVICE_ASSETS[name] if k.endswith('_unit') or state=='running')
require(type(value['assets']) is dict and set(value['assets'])==expected,'required asset set')
for digest in value['assets'].values():
require(type(digest) is str and len(digest)==64 and all(c in '0123456789abcdef' for c in digest),'asset digest encoding')
require(all(v in ('running','stopped','uninstalled') for v in value['services'].values()),'unknown service disposition')
if version==2:
commands=value['service_argv_sha256']
require(type(commands) is dict and set(commands)==set(UNITS),'service invocation appointments')
for name,expected in commands.items():
if value['services'][name]=='running' and name!='wg':
require(type(expected) is str and len(expected)==64 and all(c in '0123456789abcdef' for c in expected),
'service command digest')
else:require(expected is None,'unexpected inactive/kernel-only command appointment')
routes=value['required_routes']
require(type(routes) is list and 1<=len(routes)<=128 and all(type(n) is str for n in routes)
and len(set(routes))==len(routes) and 'management' in routes,'required route declarations')
for service,route in (('fips','fips-dns'),('tor','tor-socks')):
require(value['services'][service]!='running' or route in routes,'required live service route '+service)
require(type(value['optional_unavailable']) is list and set(value['optional_unavailable'])==
{name for name,state in value['services'].items() if state!='running'},'optional capability accounting')
class Contract:
def __init__(self,launch):
self.launch=launch;self.assets={};self.processes={};self.initial=None
self.policy_path=POLICIES/(launch['admission_sha256']+'.json')
self.raw=c01_network_owner.protected_read(self.policy_path)
require(hashlib.sha256(self.raw).hexdigest()==launch['admission_sha256'],'policy binding')
self.value=json.loads(self.raw,object_pairs_hook=c01_network_owner.exact)
validate(self.value,launch)
prepare_descriptor_budget()
self.paths=dict(ASSETS)
for mapping in SERVICE_ASSETS.values():self.paths.update(mapping)
try:
for name,digest in self.value['assets'].items():self.assets[name]=open_asset(self.paths[name],digest)
frontend=json.loads(Path(ASSETS['frontend_manifest']).read_text())
require(type(frontend) is dict and 2<=len(frontend)<=2048,'frontend closure')
for relative,digest in frontend.items():
require(type(relative) is str and not relative.startswith('/') and '..' not in Path(relative).parts,'frontend asset path')
self.assets['web:'+relative]=open_asset('/opt/archipelago/web-ui/'+relative,digest)
fips=json.loads(Path(ASSETS['fips_plan']).read_text())
require(fips['fips']==self.value['services']['fips'] and fips['wg']==self.value['services']['wg'],'FIPS/WG disposition binding')
require(set(fips['assets'])==set(FIPS_ASSETS) and fips['programs']=={k:self.value['assets'][k] for k in ('wg','systemctl')},'FIPS/WG asset/tool binding')
for name,path in FIPS_ASSETS.items():
key='fips:'+name;self.paths[key]=path
self.assets[key]=open_asset(path,fips['assets'][name])
if name=='fips.key':require(not os.fstat(self.assets[key][0]).st_mode&0o077,'FIPS key custody')
public=Path('/etc/fips/fips.pub').read_bytes()
expected=(bytes.fromhex(self.value['fips_public_key']) if self.value['version']==2
else bytes(launch['binding']['local_identity']))
require_public_identity(public,expected)
# The native FD6 StartupPolicy checks all FIPS assets, secret/public
# consistency and Running probes before background/application READY.
for path in ('/etc/tor','/var/lib/tor','/var/lib/archipelago-c01/tor'):
m=Path(path).lstat();require(stat.S_ISDIR(m.st_mode) and not m.st_mode&0o022,'Tor custody')
from c01_network_settings_owner import Journal,open_journal
fd=open_journal()
try:require(not Journal(fd).blocked(),'unreconciled network mutation')
finally:os.close(fd)
self.check()
except BaseException:self.close();raise
def command(self,name,args):
fd=self.assets[name][0]
parent=os.getpid()
def child_guard():
libc=ctypes.CDLL(None,use_errno=True)
if libc.prctl(1,9,0,0,0) or os.getppid()!=parent:os._exit(125)
p=subprocess.run([ASSETS[name],*args],executable=f'/proc/self/fd/{fd}',pass_fds=(fd,),
close_fds=True,stdin=subprocess.DEVNULL,capture_output=True,timeout=2,
preexec_fn=child_guard,env={'PATH':'/usr/bin:/bin','LANG':'C'})
require(len(p.stdout)<=16384 and len(p.stderr)<=16384 and p.returncode==0,'read-only '+name+' probe failed')
return p.stdout.decode('utf-8')
def check(self):
require(c01_network_owner.protected_read(self.policy_path)==self.raw,'policy lifetime changed')
for name,(fd,meta) in self.assets.items():
path='/opt/archipelago/web-ui/'+name[4:] if name.startswith('web:') else self.paths[name]
require(identity(os.fstat(fd))==meta and identity(Path(path).lstat())==meta,'asset lifetime '+name)
raw=self.command('systemctl',['show',*UNITS.values(),'-p','Id','-p','LoadState','-p','ActiveState','-p','MainPID','-p','InvocationID',
'-p','FragmentPath','-p','DropInPaths'])
states={}
for block in raw.strip().split('\n\n'):
item=dict(line.split('=',1) for line in block.splitlines() if '=' in line)
if 'Id' in item:states[item['Id']]=item
require(set(states)==set(UNITS.values()),'complete service observation')
for name,unit in UNITS.items():
current=states[unit];disposition=self.value['services'][name]
if disposition!='uninstalled':
unit_path=next(path for key,path in SERVICE_ASSETS[name].items() if key.endswith('_unit'))
require(current.get('FragmentPath') and str(Path(current['FragmentPath']).resolve())==unit_path and
current.get('DropInPaths')=='','unappointed effective service unit '+name)
if disposition=='uninstalled':require(current['LoadState']=='not-found' and current['MainPID']=='0','uninstalled '+name+' differs')
elif disposition=='stopped':require(current['ActiveState'] in ('inactive','failed') and current['MainPID']=='0','stopped '+name+' differs')
else:
require(current['ActiveState']=='active','required '+name+' inactive')
pid=int(current['MainPID'])
require(name=='wg' or pid>1,'required daemon MainPID '+name)
if pid:
require(c01_network.process_namespace(pid)==c01_network.process_namespace(1),'service namespace '+name)
program=next((k for k in SERVICE_ASSETS[name] if k.endswith('_program')),None)
if program:
m=os.stat(f'/proc/{pid}/exe');require((m.st_dev,m.st_ino)==self.assets[program][1][:2],'service executable '+name)
if self.value['version']==2:
with open(f'/proc/{pid}/cmdline','rb') as command:
argv=command.read(16385)
require(0<len(argv)<=16384 and hashlib.sha256(argv).hexdigest()==self.value['service_argv_sha256'][name],
'service command differs from appointment '+name)
if name not in self.processes:self.processes[name]=(pid,os.pidfd_open(pid))
require(pid==self.processes[name][0] and not read_ready(self.processes[name][1]),'service process lifetime '+name)
interfaces=self.command('wg',['show','interfaces']).split()
if self.value['services']['wg']!='running':require('wg0' not in interfaces,'unexpected managed WG interface')
else:
require('wg0' in interfaces,'managed WG absent')
require(self.command('wg',['show','wg0','public-key']).strip()==Path('/etc/archipelago-fips-wg/wg.public').read_text().strip(),'WG identity drift')
if self.initial is None:self.initial=states
else:require(states==self.initial,'service invocation/disposition changed')
def close(self):
for fd,_ in self.assets.values():os.close(fd)
self.assets={}
for _,fd in self.processes.values():os.close(fd)
self.processes={}
class Hold:
def __init__(self,owner,launch,close_except,packet):
def child(control):
close_except({control.fileno()})
c01_network.enter_host_worker(ctypes.CDLL(None,use_errno=True))
contract=Contract(launch)
try:
while True:
contract.check();packet(control,b'ADMISSION_HELD')
if read_ready(control,.5):raise ValueError('admission checker revoked')
finally:contract.close()
self.record=owner.children.spawn('admission',child,())
owner.wait(self.record,b'ADMISSION_HELD',0,0,time.clock_gettime_ns(time.CLOCK_BOOTTIME)+10_000_000_000)
self.deadline=time.clock_gettime(time.CLOCK_BOOTTIME)+3
def check(self):
while read_ready(self.record.channel):
raw,anc,flags,_=self.record.channel.recvmsg(65,socket.CMSG_SPACE(12),socket.MSG_CMSG_CLOEXEC)
require(raw==b'ADMISSION_HELD' and flags&~socket.MSG_CMSG_CLOEXEC==0 and
anc==[(socket.SOL_SOCKET,socket.SCM_CREDENTIALS,struct.pack('3i',self.record.pid,0,0))],'checker credentials/progress')
self.deadline=time.clock_gettime(time.CLOCK_BOOTTIME)+3
require(time.clock_gettime(time.CLOCK_BOOTTIME)<self.deadline,'checker lifetime lost')