285 lines
16 KiB
Python
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')
|