415 lines
23 KiB
Python
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()
|