Files
archy/tests/regression/radio-owner.py
T

415 lines
23 KiB
Python

#!/usr/bin/env python3
"""Real root/app/runtime credential/channel tests in a private PID1 service.
PTY ONLY: constructor injection is used; production appointment parsing is not
bypassed or broadened. No physical tty paths are opened by this fixture.
"""
import array
import ctypes
import fcntl
import hashlib
import json
import mmap
import os
from pathlib import Path
import select
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import termios
import time
repo = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(repo/'scripts'))
import c01_radio_owner as radio
APP, RUNTIME = 53001, 53002
def receive(channel):
raw, control, flags, _ = channel.recvmsg(65536, socket.CMSG_SPACE(16), socket.MSG_CMSG_CLOEXEC)
assert raw and not flags & ~socket.MSG_CMSG_CLOEXEC, 'closed or truncated response'
rights = []
for level, kind, data in control:
assert (level, kind) == (socket.SOL_SOCKET, socket.SCM_RIGHTS)
values = array.array('i'); values.frombytes(data[:len(data)//4*4]); rights.extend(values)
return json.loads(raw), rights
class Client:
def __init__(self, channel): self.channel, self.seq = channel, 0
def call(self, op, args=None, *, seq=None, end=None):
self.seq += 1
radio.send(self.channel, {'v':1, 'seq':self.seq if seq is None else seq,
'end':radio.now()+1_500_000_000 if end is None else end, 'op':op, 'args':args or {}})
response, rights = receive(self.channel)
assert response['seq'] == self.seq and response['v'] == 1
if 'error' in response:
assert not rights
return response, []
return response['value'], rights
def acquire(self, info):
value, rights = self.call('acquire', {'token':info['token']})
assert value == info and len(rights) == 1
channel = socket.socket(fileno=rights[0]); channel.settimeout(3)
return Client(channel)
def main():
assert os.geteuid() == 0 and os.environ.get('ARCHY_RADIO_FIXTURE') == '1'
assert os.stat('/proc/1/ns/pid').st_ino == os.stat('/proc/self/ns/pid').st_ino
sentinel=Path('/tmp/radio-custody-sentinel');sentinel.write_bytes(b'private-fixture-preserved');sentinel.chmod(0o600)
with tempfile.TemporaryDirectory(prefix='radio-owner-') as directory:
root = Path(directory); root.chmod(0o755)
home = root/'home'; home.mkdir(mode=0o700); os.chown(home, RUNTIME, RUNTIME)
helper = root/'helper'
subprocess.run(['/usr/bin/cc', '-O0', str(repo/'scripts/tests/radio_helper_fixture.c'), '-o', str(helper)], check=True)
helper.chmod(0o755)
frozen = os.environ.get('ARCHY_RADIO_FROZEN') == '1'
if frozen: helper = Path('/opt/radio-artifact/archy-rnodeconf')
fixture = json.loads(Path('/opt/radio-artifact/pty-verification.json').read_text()) if frozen else None
if fixture:
for name, encoded in fixture['keys'].items():
target = home/'.config/rnodeconf'/name
target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(bytes.fromhex(encoded))
for target in [*home.rglob('*')]:
os.chown(target,RUNTIME,RUNTIME)
target.chmod(0o700 if target.is_dir() else 0o600)
tool = os.open(helper, os.O_RDONLY|os.O_CLOEXEC)
esptool = os.open('/opt/radio-artifact/archy-esptool', os.O_RDONLY|os.O_CLOEXEC) if frozen else None
daemon = os.open('/opt/radio-artifact/archy-reticulum-daemon', os.O_RDONLY|os.O_CLOEXEC) if frozen else None
homefd = os.open(home, os.O_RDONLY|os.O_DIRECTORY|os.O_CLOEXEC)
master, slave = os.openpty(); os.set_blocking(master, False)
try:
radio.Appointment(master, '/dev/ttyUSB0', radio.identity(slave), 'heltecv3')
except ValueError: pass
else: raise AssertionError('different TTY capability accepted under aliased identity')
with open('/dev/null','rb') as wrong:
try: radio.Appointment(wrong.fileno(), '/dev/ttyUSB0', radio.identity(wrong.fileno()), 'heltecv3')
except ValueError: pass
else: raise AssertionError('non-TTY device capability accepted')
label = os.ttyname(slave)
assert label.startswith('/dev/pts/')
os.chmod(label, 0o666) # PTY-only kernel TIOCEXCL negative test, not production ACL policy.
appointment = radio.Appointment(slave, label, radio.identity(slave), 'heltecv3')
config = home/'.archy-reticulum'; config.mkdir()
(config/'config').write_text('[interfaces]\n [[RNode LoRa]]\n type = RNodeInterface\n enabled = yes\n'
f' port = {label}\n frequency = 869525000\n bandwidth = 62500\n spreadingfactor = 9\n codingrate = 6\n txpower = 10\n airtime_limit_short = 25\n')
os.chown(config,RUNTIME,RUNTIME);os.chown(config/'config',RUNTIME,RUNTIME)
delayed = root/'delay-entry'
corruption = mmap.mmap(-1,16)
responder = None
if fixture:
responder = os.fork()
if responder == 0:
os.close(slave)
def frame(command,data):
return b'\xc0'+bytes([command])+data.replace(b'\xdb',b'\xdb\xdd').replace(b'\xc0',b'\xdb\xdc')+b'\xc0'
pending=b''
try:
while True:
if select.select([master],[],[],.1)[0]:
data=os.read(master,4096)
struct.pack_into('Q',corruption,8,struct.unpack_from('Q',corruption,8)[0]+len(data))
if b'\xc0' in data:
os.write(master,bytes.fromhex(fixture['corrupt' if corruption[0] else 'valid']))
pending+=data
pieces=pending.split(b'\xc0');pending=pieces.pop()
for packet in pieces:
packet=packet.replace(b'\xdb\xdc',b'\xc0').replace(b'\xdb\xdd',b'\xdb')
if len(packet)>1 and packet[0] in (1,2,3,4,5,6,7,11,12):
os.write(master,frame(packet[0],packet[1:]))
elif packet[:1]==b'\x00':os.write(master,frame(15,b'\x01'))
except OSError: pass
os._exit(0)
group = Path('/sys/fs/cgroup')/Path('/proc/self/cgroup').read_text().strip().removeprefix('0::/')
endpoint = str(root/'control.sock')
pid = os.fork()
if pid == 0:
os.close(master)
# SO_PEERCRED must name the actual independently retained owner,
# not the fixture's PID1 creator of a pre-fork listener.
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
listener.setsockopt(socket.SOL_SOCKET,socket.SO_PASSCRED,1)
listener.bind(endpoint); listener.listen(8)
def enter():
if delayed.exists(): time.sleep(2.1)
if frozen:
# An actual private /dev view: the granted open PTY survives,
# while reopening its pathname is impossible in the helper.
import ctypes
libc=ctypes.CDLL(None,use_errno=True)
libc.mount.argtypes=[ctypes.c_char_p,ctypes.c_char_p,ctypes.c_char_p,ctypes.c_ulong,ctypes.c_char_p]
assert libc.unshare(0x00020000)==0
assert libc.mount(None,b'/',None,16384|(1<<18),None)==0
assert libc.mount(b'tmpfs',b'/dev/pts',b'tmpfs',2|4|8,b'mode=000,size=65536')==0
try: radio.Owner(appointment, RUNTIME, RUNTIME, homefd, {'rnode':tool, 'esptool':esptool, 'daemon':daemon}, group, lambda: fixture['keys'] if fixture else None, (APP,APP), enter=enter).serve(listener)
finally: os._exit(0)
os.close(slave)
end=time.monotonic()+3
while not Path(endpoint).exists():
assert time.monotonic()<end; time.sleep(.01)
def scenario(fn, native=False):
channel = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET); channel.settimeout(3); channel.connect(endpoint)
ready_r, ready_w = os.pipe()
child = os.fork()
if child == 0:
try:
os.close(ready_w)
os.setgroups([]); os.setgid(APP); os.setuid(APP)
assert ctypes.CDLL(None).prctl(38,1,0,0,0)==0
os.write(1, b'')
assert os.read(ready_r, 1) == b'!'
for fd in (master, tool, homefd): os.close(fd)
if esptool is not None: os.close(esptool)
if daemon is not None: os.close(daemon)
if native:
os.dup2(channel.fileno(),14,inheritable=True)
os.environ['ARCHY_RADIO_NATIVE_CLIENT']='1'
binary=os.environ['ARCHY_RADIO_CLIENT']
os.execv(binary,[binary,'--exact','mesh::owned_serial::tests::appointed_roundtrip','--ignored','--nocapture'])
fn(Client(channel))
os._exit(0)
except BaseException:
import traceback; traceback.print_exc(); os._exit(1)
os.close(ready_r)
# Wait for actual credential drop; app remains blocked on its gate.
end = time.monotonic()+2
while f'Uid:\t{APP}\t{APP}\t{APP}\t{APP}' not in Path(f'/proc/{child}/status').read_text() or 'NoNewPrivs:\t1' not in Path(f'/proc/{child}/status').read_text():
assert time.monotonic()<end; time.sleep(.005)
radio.send(channel, {'bind':[child, APP, APP]})
assert receive(channel) == ({'bound':[child,APP,APP]}, [])
os.write(ready_w,b'!'); os.close(ready_w); channel.close()
result = os.waitpid(child,0)[1]
assert os.waitstatus_to_exitcode(result) == 0
time.sleep(.1)
def io_and_tools(client):
info, rights = client.call('inventory'); assert not rights
rf,_=client.call('rf')
assert rf['bandwidth']==62500 and rf['spreading_factor']==9 and rf['airtime_limit_short']==25
session = client.acquire(info)
busy, _ = client.call('acquire', {'token':info['token']}); assert 'error' in busy
assert session.call('configure', {'baud':115200}) == (True,[])
assert session.call('write', {'data':b'RPC'.hex()}) == (3,[])
assert session.call('run', {'action':'capabilities','board':'heltecv3','band':'1','nonce':'a'*32}) == (True,[])
end=time.monotonic()+5
while True:
value,_=session.call('poll')
if value['done']: break
assert time.monotonic()<end; time.sleep(.02)
assert value['result']['code']==0, value
proof=json.loads(value['result']['output'])
if frozen: assert proof == {'contract':1}
else: assert proof['uid']==RUNTIME and proof['inode']==info['identity'][1] and proof['rdev']==info['identity'][2]
session.channel.close()
time.sleep(.1)
replacement=client.acquire(info); replacement.channel.close()
try:
scenario(io_and_tools, native='ARCHY_RADIO_CLIENT' in os.environ)
if not frozen: assert os.read(master,4096)==b'RPCOWNED'
print('PASS real root/app/runtime credentials, inherited identical open TTY, exclusion, IO and lease reuse',flush=True)
if frozen:
def verify(client):
info,_=client.call('inventory'); session=client.acquire(info)
session.call('run',{'action':'verify','board':'heltecv3','band':'1','nonce':'d'*32})
end=time.monotonic()+20
while True:
value,_=session.call('poll')
if value['done']: break
assert time.monotonic()<end; time.sleep(.02)
result=value['result']
if corruption[0]:
assert result['code']!=0, result
else:
assert result['code']==0, result
receipt=json.loads(next(line for line in reversed(result['output'].splitlines()) if line.startswith('{')))
assert receipt['nonce']=='d'*32 and receipt['path']==label and receipt['board']=='heltecv3' and receipt['signature_valid'] is True
assert receipt['sha256']==(b'a'*32).hex(),receipt
scenario(verify)
corruption[0]=1
scenario(verify)
def rom_refusal(client):
info,_=client.call('inventory'); session=client.acquire(info)
image=b'\xe9'+b'\0'*4095
session.call('image',{'size':len(image),'sha256':hashlib.sha256(image).hexdigest()})
session.call('image-chunk',{'data':image.hex()})
before=struct.unpack_from('Q',corruption,8)[0]
for action in ('write','write-slow'):
session.call('run',{'action':action,'board':'heltecv3','band':'1','nonce':'e'*32})
end=time.monotonic()+40
while True:
value,_=session.call('poll')
if value['done']:break
assert time.monotonic()<end;time.sleep(.05)
assert value['result']['code']!=0,value
assert '4.8.1' in value['result']['output'],value
assert struct.unpack_from('Q',corruption,8)[0]>before, 'esptool never wrote the appointed FD'
scenario(rom_refusal)
corruption[0]=0
def listener_session(client):
info,_=client.call('inventory');session=client.acquire(info)
_,rights=session.call('daemon',{'identity':fixture['radio_identity'],'display_name':'Retained fixture',
'ed_pub':None,'x_pub':None,'rf':{'frequency':869525000,'bandwidth':62500,'spreading_factor':9,
'coding_rate':6,'txpower':10,'airtime_limit_short':25,'airtime_limit_long':None}})
assert len(rights)==1
rpc=socket.socket(fileno=rights[0]);rpc.settimeout(20)
reader=rpc.makefile('rb')
try:
line=reader.readline()
if not line:
failure,_=session.call('poll')
raise AssertionError(('daemon closed before ready',failure))
ready=json.loads(line)
assert ready['event']=='ready' and ready['dest_hash']==fixture['destination'],ready
rpc.sendall(b'{"cmd":"radio_state"}\n')
while True:
state=json.loads(reader.readline())
if state.get('event')=='radio_state':break
assert state['online'] and state['r_frequency']==869525000 and state['r_bandwidth']==62500 and state['r_spreadingfactor']==9,state
busy,_=client.call('acquire',{'token':info['token']});assert 'error' in busy
finally:
reader.close();rpc.close();session.channel.close()
end=time.monotonic()+5
while True:
value,rights=client.call('acquire',{'token':info['token']})
if 'error' not in value:os.close(rights[0]);break
assert time.monotonic()<end;time.sleep(.02)
scenario(listener_session)
for name,encoded in fixture['keys'].items():
assert (home/'.config/rnodeconf'/name).read_bytes()==bytes.fromhex(encoded)
print('PASS exact frozen helper through ordinary runtime owner with appointed exclusive PTY FD', flush=True)
print('PASS frozen RNS KISS/RSA verification, nonce/device receipt, corrupt-signature refusal, retained keys unchanged',flush=True)
print('PASS frozen esptool sends ROM handshake over hidden-path appointed FD; non-ROM PTY refuses write and fixed-baud retry',flush=True)
print('PASS frozen Reticulum listener same-FD startup, preserved destination/RF readback, flash exclusion and owned shutdown',flush=True)
assert not list(radio.WORK.iterdir()), 'frozen extraction workspace leaked'
return
def bad(client, kind):
info,_=client.call('inventory')
try:
if kind=='replay': client.call('inventory',seq=1)
elif kind=='late': client.call('inventory',end=radio.now()-1)
elif kind=='alias': client.call('acquire',{'token':info['token'],'path':'/dev/null'})
elif kind=='rights':
read,write=os.pipe()
try:
raw=json.dumps({'v':1,'seq':2,'end':radio.now()+1_000_000_000,'op':'inventory','args':{}}).encode()
client.channel.sendmsg([raw],[(socket.SOL_SOCKET,socket.SCM_RIGHTS,array.array('i',[read]))])
receive(client.channel)
finally: os.close(read); os.close(write)
elif kind=='foreign':
foreign=os.fork()
if foreign==0:
try: client.call('inventory')
except (AssertionError, ConnectionResetError): os._exit(0)
os._exit(1)
assert os.waitstatus_to_exitcode(os.waitpid(foreign,0)[1])==0
return
else: client.call('acquire',{'token':'0'*64})
except (AssertionError, BrokenPipeError, ConnectionResetError): return
raise AssertionError('bad request accepted')
def stable_fd_count():
# The finite tick opens /proc/children and cgroup.events briefly;
# sampling across turns distinguishes those from retained leaks.
counts=[]
for _ in range(10):
counts.append(len(list(Path(f'/proc/{pid}/fd').iterdir())))
time.sleep(.02)
return min(counts)
fd_count=stable_fd_count()
for kind in ('replay','late','alias','stale','rights','foreign'):
scenario(lambda c: bad(c,kind))
assert stable_fd_count() <= fd_count, 'rejected ancillary rights leaked'
print('PASS replay, late queued job, path alias and stale appointment refusal',flush=True)
delayed.touch(mode=0o600)
def late_exec(client):
info,_=client.call('inventory'); session=client.acquire(info)
session.call('run',{'action':'capabilities','board':'heltecv3','band':'1','nonce':'c'*32})
end=time.monotonic()+5
while True:
value,_=session.call('poll')
if value['done']: break
assert time.monotonic()<end; time.sleep(.02)
assert value['result']['code']==125 and not value['result']['output'], value
scenario(late_exec)
delayed.unlink()
print('PASS helper admission deadline checked after namespace/credential preparation, before exec',flush=True)
def abandoned(client):
info,_=client.call('inventory'); session=client.acquire(info)
session.call('run', {'action':'install','board':'heltecv3','band':'1','nonce':'b'*32})
end=time.monotonic()+3
while True:
running,_=session.call('poll')
if 'descendant=' in running['output']: break
assert not running['done'] and time.monotonic()<end; time.sleep(.01)
session.channel.close()
end=time.monotonic()+5
while True:
value,rights=client.call('acquire',{'token':info['token']})
if 'error' not in value:
assert len(rights)==1; os.close(rights[0]); break
assert time.monotonic()<end; time.sleep(.02)
scenario(abandoned)
time.sleep(2.2)
try: data=os.read(master,4096)
except BlockingIOError: data=b''
assert b'ORPHAN' not in data
assert not list(group.glob('radio-*')), 'helper cgroup leaked after retirement'
print('PASS cancellation retires setsid descendants before radio lease reuse',flush=True)
inherited_pid=mmap.mmap(-1,8)
def caller_died(client):
info,_=client.call('inventory'); session=client.acquire(info)
session.call('run',{'action':'install','board':'heltecv3','band':'1','nonce':'e'*32})
end=time.monotonic()+3
while True:
value,_=session.call('poll')
if 'descendant=' in value['output']: break
assert time.monotonic()<end; time.sleep(.01)
child=os.fork()
if child==0: time.sleep(3); os._exit(0)
inherited_pid[:]=struct.pack('Q',child)
os._exit(0) # inherited channel remains open in the different PID
scenario(caller_died)
def after_death(client):
info,_=client.call('inventory')
end=time.monotonic()+3
while True:
value,rights=client.call('acquire',{'token':info['token']})
if 'error' not in value: os.close(rights[0]); return
assert time.monotonic()<end; time.sleep(.02)
scenario(after_death)
os.waitpid(struct.unpack('Q',inherited_pid[:])[0],0); inherited_pid.close()
try: data=os.read(master,4096)
except BlockingIOError: data=b''
assert b'ORPHAN' not in data
print('PASS caller pidfd revokes inherited channels and retires runtime writers after application death',flush=True)
os.close(master); master=os.open('/dev/null',os.O_RDONLY)
def unplug(client):
try: client.call('inventory')
except (AssertionError, ConnectionResetError): return
raise AssertionError('unplugged appointment accepted')
scenario(unplug)
print('PASS PTY disconnect retires appointment; no pathname reopen',flush=True)
assert sentinel.read_bytes()==b'private-fixture-preserved'
assert not list(radio.WORK.iterdir()), 'radio workspace leaked'
finally:
os.kill(pid,signal.SIGTERM); os.waitpid(pid,0)
if responder is not None:
os.kill(responder,signal.SIGTERM); os.waitpid(responder,0)
corruption.close()
os.close(master); os.close(tool); os.close(homefd)
if esptool is not None: os.close(esptool)
if daemon is not None: os.close(daemon)
if __name__=='__main__': main()