"""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<>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