diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests/test_isolated_keycape_session.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests/test_isolated_keycape_session.py new file mode 100644 index 00000000..77b62992 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests/test_isolated_keycape_session.py @@ -0,0 +1,243 @@ +import base64 +import importlib.util +import json +import io +import os +import tempfile +import signal +import subprocess +import sys +import time +from pathlib import Path +import unittest +from unittest.mock import patch + +SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py' +spec=importlib.util.spec_from_file_location('session',SOURCE) +s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) + +class SessionTests(unittest.TestCase): + def test_tmpfs_plan_has_four_exact_original_mounts(self): + session=s.plan_session() + self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS)) + self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values())) + self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE) + def test_non_tmpfs_driver_options_refused(self): + session=s.plan_session();name,spec=next(iter(session['volumes'].items())) + wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}] + with patch.object(s,'run',return_value=json.dumps(wrong).encode()): + with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id']) + def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self): + session=s.plan_session();events=[] + payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}} + def run(argv,**kwargs): + events.append(('run',argv,kwargs)) + if argv[-1]=='{{.Id}}':return b'anchor' + return b'' + with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))): + s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example') + joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run') + self.assertNotIn('fixture-sensitive',joined) + private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1]) + inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect') + self.assertLess(inspect_index,private_index) + issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE) + self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive') + self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1]) + self.assertNotIn('--entrypoint',issuer_call[1]) + def test_non_original_https_port_refused_before_any_mutation(self): + with patch.object(s,'run') as run: + with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example') + run.assert_not_called() + def test_container_failure_still_attempts_each_volume_cleanup(self): + session=s.plan_session();receipt={};removed=[] + def run(argv,**kwargs): + if argv[1:3]==['volume','rm']:removed.append(argv[-1]) + if argv[1:3]==['volume','ls']: + name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$') + return (name+'\n').encode() if name not in removed else b'' + return b'' + with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run): + with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt) + self.assertEqual(set(removed),set(session['volumes'])) + self.assertFalse(receipt['container_cleanup_verified']) + self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4) + def test_reaper_inherits_no_custody_or_service_environment(self): + session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']} + with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'verify_execution'),patch.object(s.os,'pidfd_open',return_value=99),patch.object(s.os,'close'),patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])): + spawn.return_value.stdout.readline.return_value=b'registered\n' + s.start_reaper(session,staging,{'pid':123}) + kwargs=spawn.call_args.kwargs + self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'}) + self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']);self.assertEqual(kwargs['pass_fds'],(99,)) + +class TargetBindingTests(unittest.TestCase): + def test_every_direct_docker_command_is_explicitly_bound(self): + with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run: + self.assertEqual(s.run(['docker','info']),b'ok') + argv=run.call_args.args[0] + self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT]) + self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT) + def test_nondefault_unix_socket_is_refused_before_any_docker_call(self): + with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) + run.assert_not_called() + def test_conflicting_context_is_refused(self): + with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) + run.assert_not_called() + def test_conflicting_per_call_host_is_refused(self): + with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'}) + run.assert_not_called() + def test_reused_preflight_uses_same_bound_command(self): + self.assertIs(s.local.command,s.adapter.command) + def test_probe_is_registered_before_session_creation(self): + session=s.plan_session() + self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe') + self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT) + +class PlaintextLifecycleTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name) + self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop) + self.run_id='a'*32 + self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700) + self.staging=s.register_workdir(self.work,self.run_id) + def test_exact_plaintext_directory_removed_and_recorded(self): + (self.work/'archive.tar').write_bytes(b'synthetic-private-archive') + restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic') + receipt={};s.cleanup_workdir(self.staging,receipt) + self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) + self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified') + def test_docker_failure_does_not_skip_plaintext_cleanup(self): + (self.work/'archive.tar').write_bytes(b'synthetic') + receipt={} + with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused): + with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt) + self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) + def test_replacement_inode_is_not_removed(self): + # Preserve original inode while replacing its registered pathname. + old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700) + (self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={} + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt) + self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists()) + self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed') + def test_symlink_root_is_not_followed(self): + self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep') + self.work.symlink_to(outside,target_is_directory=True) + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) + self.assertTrue((outside/'kept').exists()) + def test_descendant_symlink_is_not_followed(self): + outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside) + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) + self.assertEqual(outside.read_bytes(),b'keep') + def test_wrong_owner_identity_is_not_removed(self): + changed={**self.staging,'owner_uid':os.getuid()+1} + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{}) + self.assertTrue(self.work.exists()) + def test_unregistered_path_refused(self): + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {}) + +@unittest.skipUnless(hasattr(os,'pidfd_open') and hasattr(signal,'pidfd_send_signal'),'Linux pidfd required') +class ExecutionDeadlineTests(unittest.TestCase): + def start_synthetic_owner(self,root): + late=str(root/'late-creation') + code="""import importlib.util,json,subprocess,sys,time +spec=importlib.util.spec_from_file_location('session',sys.argv[1]);s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) +execution=s.isolate_execution() +child=subprocess.Popen([sys.executable,'-c',"import pathlib,sys,time;time.sleep(2);pathlib.Path(sys.argv[1]).write_text('late')",sys.argv[2]]) +print(json.dumps({'execution':execution,'child_pid':child.pid}),flush=True) +time.sleep(20) +""" + parent=subprocess.Popen([sys.executable,'-c',code,str(SOURCE),late],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL) + message=json.loads(parent.stdout.readline());parent.stdout.close() + def finish(): + if parent.poll() is None:parent.kill() + parent.wait(timeout=5) + try: + before=s.process_facts(message['child_pid']) + if before is None or before['sid']!=message['execution']['sid'] or before['pgid']!=message['execution']['pgid']:return + fd=os.pidfd_open(message['child_pid']) + try: + after=s.process_facts(message['child_pid']) + if after is not None and all(after[k]==before[k] for k in ('pid','sid','pgid','start_ticks','uid')): + signal.pidfd_send_signal(fd,signal.SIGKILL) + except ProcessLookupError:pass + finally:os.close(fd) + except ProcessLookupError:pass + self.addCleanup(finish) + return parent,message['execution'],Path(late) + def test_deadline_quiesces_only_fresh_owner_group_before_cleanup(self): + with tempfile.TemporaryDirectory() as directory: + parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid) + try: + receipt={};s.quiesce_execution(execution,fd,receipt) + self.assertTrue(receipt['execution_quiescence_verified']) + self.assertFalse(s.group_members(execution));self.assertFalse(late.exists()) + finally:os.close(fd) + def test_hard_parent_loss_stops_surviving_marker_creator(self): + with tempfile.TemporaryDirectory() as directory: + parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid) + try: + signal.pidfd_send_signal(fd,signal.SIGKILL) + poll=__import__('select').poll();poll.register(fd,__import__('select').POLLIN);self.assertTrue(poll.poll(2000)) + receipt={};s.quiesce_execution(execution,fd,receipt) + self.assertTrue(receipt['execution_quiescence_verified']);self.assertFalse(late.exists()) + self.assertFalse(s.group_members(execution)) + finally:os.close(fd) + def test_expired_execution_cannot_start_any_docker_command(self): + binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()-1,'staging':'/tmp/not-created'} + with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','create','fixed-image']) + run.assert_not_called() + def test_reused_or_foreign_session_refuses_signals(self): + binding={'pid':123,'sid':123,'pgid':123,'start_ticks':10,'uid':os.getuid()} + wrong={**binding,'sid':456,'state':'S'} + with patch.object(s,'process_facts',return_value=wrong),patch.object(s.signal,'pidfd_send_signal') as send: + with self.assertRaises(s.adapter.Refused):s.quiesce_execution(binding,99,{}) + send.assert_not_called() + def test_inflight_uncertainty_survives_plaintext_directory_removal(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'owned-stage';path.mkdir();marker=Path(str(path)+'-docker-inflight');marker.touch() + staging={'path':str(path)};self.assertTrue(s.daemon_uncertain(staging));path.rmdir() + self.assertTrue(s.daemon_uncertain(staging)) + +class ReaperOrderingTests(unittest.TestCase): + def exercise(self,quiescence_fails=False): + events=[];records=[] + def quiet(*args): + events.append('quiesce') + if quiescence_fails:raise s.adapter.Refused() + args[-1]['execution_quiescence_verified']=True + def recover(*args):events.append('cleanup') + with patch.object(s,'verify_execution'),patch.object(s,'pidfd_dead',return_value=False),patch.object(s.select,'poll') as poll,patch.object(s,'quiesce_execution',side_effect=quiet),patch.object(s,'daemon_uncertain',return_value=True),patch.object(s,'recover_obligations',side_effect=recover),patch.object(s.os,'close'),patch.object(s.os,'open',return_value=99),patch.object(s.os,'fdopen'),patch.object(s.json,'dump',side_effect=lambda r,*args:records.append(dict(r))),patch('sys.stdout',io.StringIO()): + poll.return_value.poll.return_value=[] + s.reaper_main({}, {'path':'/dev/shm/exact-staging'}, {'deadline':time.monotonic()-1},77) + return events,records[0] + def test_daemon_uncertainty_survives_verified_resource_readback(self): + events,receipt=self.exercise();self.assertEqual(events,['quiesce','cleanup']) + self.assertTrue(receipt['execution_quiescence_verified']);self.assertTrue(receipt['in_flight_daemon_request_uncertain']) + self.assertEqual(receipt['status'],'bounded_cleanup_uncertain') + def test_unverified_quiescence_prevents_any_cleanup(self): + events,receipt=self.exercise(True);self.assertEqual(events,['quiesce']) + self.assertFalse(receipt['execution_quiescence_verified']);self.assertEqual(receipt['status'],'bounded_cleanup_uncertain') + def test_checkpoint_rewrites_one_valid_json_document(self): + with tempfile.TemporaryFile() as stream: + s.persist_receipt(stream.fileno(),{'status':'registered','long':'x'*100}) + s.persist_receipt(stream.fileno(),{'status':'failed'}) + stream.seek(0);self.assertEqual(json.load(stream),{'status':'failed'}) + def test_docker_timeout_retains_independent_uncertainty_marker(self): + with tempfile.TemporaryDirectory() as directory: + staging=str(Path(directory)/'stage');Path(staging).mkdir() + binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging} + def timeout(*args,**kwargs): + self.assertTrue(Path(staging+'-docker-inflight').exists()) + raise subprocess.TimeoutExpired('synthetic Docker',1) + with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=timeout): + with self.assertRaises(subprocess.TimeoutExpired):s.run(['docker','create','fixed-image']) + self.assertFalse(Path(staging+'-docker-inflight').exists()) + self.assertTrue(Path(staging+'-docker-uncertain').exists());self.assertTrue(s.daemon_uncertain({'path':staging})) + +if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/check-privacyidea-database.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/check-privacyidea-database.py new file mode 100644 index 00000000..d00361be --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/check-privacyidea-database.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Restore the fetched PI database in an isolated, disposable local PostgreSQL. + +Run only through the attended custody helper. No cluster, production database, +network, ports, host data mounts, IAM login or token decryption is involved. +""" +import argparse +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location('custody',ROOT/'sso-mfa/bootstrap/sops-private.py') +custody=importlib.util.module_from_spec(spec);spec.loader.exec_module(custody) +RECIPIENT='age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz' +IMAGE='postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70' +OBJECT='r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age' +SIZE=89129309 + + +class Refused(Exception):pass + + +def run(args,**kwargs): + result=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=600,**kwargs) + if result.returncode:raise Refused() + return result.stdout + + +def transfer_input(receipt): + r=json.loads(receipt.read_text()) + if (r.get('schema')!='netkingdom.privacyidea-offhost-transfer.v1' or + r.get('status')!='ciphertext_fetched' or r.get('object')!=OBJECT or + r.get('ciphertext_bytes')!=SIZE or r.get('object_unchanged_during_transfer') is not True):raise Refused() + path=Path(r['ciphertext_path']) + if not path.is_absolute() or path.parent.parent!=Path('/tmp') or not path.parent.name.startswith('nk49-offhost-'):raise Refused() + if any(p.is_symlink() for p in (path,*path.parents)):raise Refused() + st=path.stat() + if st.st_uid!=os.getuid() or st.st_mode&0o077 or st.st_nlink!=1 or not path.is_file() or st.st_size!=SIZE:raise Refused() + blob=path.read_bytes() + if len(blob)!=SIZE or hashlib.sha256(blob).hexdigest()!=r['ciphertext_sha256']:raise Refused() + return blob,r['ciphertext_sha256'] + + +def restore(dump,container): + # Require the exact image already present; do not pull through a mutable tag. + image_id=run(['docker','image','inspect','--format','{{.Id}}',IMAGE]).decode().strip() + run(['docker','run','--detach','--pull=never','--name',container,'--network','none', + '--security-opt','no-new-privileges','--memory','2g','--cpus','2', + '--tmpfs','/var/lib/postgresql/data:rw,nosuid,nodev,size=1073741824', + '--env','POSTGRES_HOST_AUTH_METHOD=trust','--env','POSTGRES_DB=recovery', + IMAGE,'postgres','-c','listen_addresses=']) + for _ in range(60): + # The image starts a temporary server during initdb. Wait until the + # entrypoint has exec'd the final server before attempting the restore. + pid1=subprocess.run(['docker','exec',container,'cat','/proc/1/comm'], + stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10) + if pid1.returncode or pid1.stdout.strip()!=b'postgres': + time.sleep(1);continue + ready=subprocess.run(['docker','exec',container,'pg_isready','-U','postgres','-d','recovery'], + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10) + if ready.returncode==0:break + time.sleep(1) + else:raise Refused() + version=run(['docker','exec',container,'pg_restore','--version']).decode().strip() + with dump.open('rb') as source: + run(['docker','exec','-i',container,'pg_restore','-U','postgres','-d','recovery', + '--no-owner','--no-privileges','--exit-on-error'],stdin=source) + # Fixed aggregate queries only. No identity, resolver config or token values. + counts={} + for table in ('token','resolver','realm'): + raw=run(['docker','exec',container,'psql','-X','-U','postgres','-d','recovery','-At', + '-v','ON_ERROR_STOP=1','-c',f'SELECT count(*) FROM public.{table};']).strip() + if not raw.isdigit():raise Refused() + counts[table]=int(raw) + return {'image':IMAGE,'image_id':image_id,'pg_restore_version':version,'aggregate_row_counts':counts} + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077) + # Reserve the receipt before any recovery action; never overwrite evidence. + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.privacyidea-database-drill.v1','status':'failed', + 'started_at':datetime.now(timezone.utc).isoformat(),'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'production_contacted':False,'secret_values_exported':False,'native_identity_recovery_proven':False, + 'token_decryption_proven':False,'audit_signature_continuity_proven':False, + 'full_identity_file_coverage_proven':False,'container_cleanup_verified':False, + 'temporary_plaintext_cleanup_verified':False} + container='nk49-pi-recovery-'+uuid.uuid4().hex + keyfd=None;work=None + try: + blob,digest=transfer_input(Path(a.transfer_receipt));r['ciphertext_sha256']=digest + keyfd=custody.private_key_fd() + derived=run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip() + if derived!=RECIPIENT.encode():raise Refused() + r['registered_recipient_match']=True + with tempfile.TemporaryDirectory(prefix='nk49-pi-drill-',dir='/tmp') as temporary: + work=Path(temporary) + env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary} + wrong=work/'wrong.key' + run(['age-keygen','-o',str(wrong)],env=env) + negative=subprocess.run(['age','-d','-i',str(wrong)],input=blob,stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL,timeout=60,env=env) + if negative.returncode==0:raise Refused() + r['wrong_key_refused']=True + dump=work/'privacyidea.dump' + with dump.open('xb') as output: + result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}'],input=blob, + stdout=output,stderr=subprocess.DEVNULL,timeout=120,pass_fds=(keyfd,),env=env) + if result.returncode or not 5max_bytes:raise Refused() + + +def allowed(name,profile_data=None): + p=PurePosixPath(name) + if p.is_absolute() or any(not NAME.fullmatch(x) or x in {'.','..'} for x in p.parts):return False + if len(p.parts)>=3 and p.parts[:2] in [('data','lldap'),('data','authelia'),('data','keycape'),('data','privacyidea')]:return True + if name=='databases/privacyidea.dump':return True + for c in (profile_data or profile())['components']: + prefix=f"resources/{c['namespace']}/" + if name==prefix+'deployment/'+c['deployment']+'.json':return True + if any(name==prefix+'secret/'+n+'.json' for n in c['secret_refs']):return True + if any(name==prefix+'configmap/'+n+'.json' for n in c['configmap_refs']):return True + return False + + +def save(root,name,value,profile_data=None): + if not allowed(name,profile_data) or len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused() + target=root/name;target.parent.mkdir(mode=0o700,parents=True,exist_ok=True) + fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as out:out.write(value) + + +def import_files(raw,root,mode): + names=set();total=0 + with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive: + for member in archive: + expected=('data/privacyidea/',) if mode=='mfa' else ('data/lldap/','data/authelia/','data/keycape/') + if (not member.isfile() or not member.name.startswith(expected) or member.name in names + or member.size<0 or member.size>FILE_LIMIT or len(names)>=2048):raise Refused() + names.add(member.name);total+=member.size + if total>LIMIT:raise Refused() + data=archive.extractfile(member).read() + if len(data)!=member.size:raise Refused() + save(root,member.name,data) + + +def resource(root,ns,kind,name): + p=root/f'resources/{ns}/{kind}/{name}.json' + data=json.loads(p.read_bytes()) + if data['metadata']['name']!=name or data['metadata']['namespace']!=ns:raise Refused() + return data + + +def checks(root,profile_data=None): + # Checks describe stored formats/integrity, not current login validity. + for c in (profile_data or profile())['components']: + deployment=resource(root,c['namespace'],'deployment',c['deployment']) + if deployment.get('kind')!='Deployment' or deployment['metadata']['uid']!=c['deployment_uid']:raise Refused() + for name in c['secret_refs']: + data=resource(root,c['namespace'],'secret',name) + if data.get('kind')!='Secret' or not isinstance(data.get('data'),dict) or not data['data']:raise Refused() + for value in data['data'].values(): + if b'AGE-SECRET-KEY-' in base64.b64decode(value,validate=True):raise Refused() + for name in c['configmap_refs']: + if resource(root,c['namespace'],'configmap',name).get('kind')!='ConfigMap':raise Refused() + for name,file in [('lldap','users.db'),('authelia','db.sqlite3')]: + db=root/f'data/{name}/{file}' + with sqlite3.connect(db.as_uri()+'?mode=ro',uri=True) as con: + if con.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused() + with (root/'databases/privacyidea.dump').open('rb') as source: + if source.read(5)!=b'PGDMP':raise Refused() + spec=importlib.util.spec_from_file_location('pi_keys',ROOT/'sso-mfa/k8s/privacyidea/key-custody-remote.py') + keys=importlib.util.module_from_spec(spec);spec.loader.exec_module(keys) + keys.validate({name:(root/'data/privacyidea'/name).read_bytes() for name in ('enckey','private.pem','public.pem')}) + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + config=resource(root,'sso','secret','keycape-config')['data'] + if not base64.b64decode(config['config.yaml'],validate=True):raise Refused() + signing=serialization.load_pem_private_key(base64.b64decode(config['key.pem'],validate=True),password=None) + if not isinstance(signing,rsa.RSAPrivateKey) or signing.key_size<2048:raise Refused() + # Policy must exist, even for an empty set of receipts; do not silently + # restore an issuer without its current authentication policy. + policy=json.loads((root/'data/keycape/policy.json').read_bytes()) + if not isinstance(policy,dict):raise Refused() + return {'sqlite_integrity_passed':True,'privacyidea_key_format_and_pair_passed':True, + 'keycape_signing_key_format_passed':True,'policy_json_present':True, + 'database_custom_format_present':True,'native_identity_recovery_proven':False} + + +def inventory(root): + private(root,True);entries=[];total=0 + for path in sorted(root.rglob('*')): + if path.is_dir():private(path,True);continue + private(path);name=path.relative_to(root).as_posix() + if not allowed(name):raise Refused() + with path.open('rb') as source:value=source.read(FILE_LIMIT+1) + if len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused() + total+=len(value) + if total>LIMIT or len(entries)>=4096:raise Refused() + entries.append({'path':name,'bytes':len(value),'sha256':hashlib.sha256(value).hexdigest()}) + if not entries:raise Refused() + return entries + + +def seal(root,output,capture): + output=output.absolute();private(output.parent,True) + if output.exists() or output.is_symlink():raise Refused() + checked=checks(root);entries=inventory(root) + manifest={'schema':'netkingdom.identity-archive.v1','profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(), + 'profile':profile(), + 'captured_at':datetime.now(timezone.utc).isoformat(),'capture':capture,'files':entries,'checks':checked} + with tempfile.TemporaryDirectory(prefix='.identity-seal-',dir=output.parent) as temporary: + ciphertext=Path(temporary)/'ciphertext' + fd=os.open(ciphertext,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + proc=subprocess.Popen(['age','-r',profile()['public_recipient']],stdin=subprocess.PIPE,stdout=fd,stderr=subprocess.DEVNULL) + os.close(fd) + try: + with tarfile.open(fileobj=proc.stdin,mode='w|') as archive: + raw=json.dumps(manifest,sort_keys=True).encode();m=tarfile.TarInfo('manifest.json');m.size=len(raw);m.mode=0o600 + archive.addfile(m,io.BytesIO(raw)) + for entry in entries: + with (root/entry['path']).open('rb') as source: + value=source.read(FILE_LIMIT+1) + if hashlib.sha256(value).hexdigest()!=entry['sha256']:raise Refused() + m=tarfile.TarInfo(entry['path']);m.size=len(value);m.mode=0o600 + archive.addfile(m,io.BytesIO(value)) + proc.stdin.close() + if proc.wait(timeout=120) or not 04096:raise Refused() + seen=set();total=0 + # Iteration starts with the already-read first member; explicitly skip + # only that member. A later duplicate manifest is never accepted. + for index,member in enumerate(archive): + if index==0 and member is first:continue + name=member.name + if (not member.isfile() or name in seen or name not in expected or not allowed(name,captured) + or not 0<=member.size<=FILE_LIMIT or member.size!=expected[name]['bytes']):raise Refused() + seen.add(name);total+=member.size + if total>LIMIT:raise Refused() + raw=archive.extractfile(member).read() + if len(raw)!=member.size or hashlib.sha256(raw).hexdigest()!=expected[name]['sha256']:raise Refused() + save(root,name,raw,captured) + if seen!=set(expected):raise Refused() + return manifest,checks(root,captured) + + +def require_runtime_host(): + import socket + host=profile()['runtime_host'] + if (os.geteuid()!=0 or socket.gethostname()!=host['hostname'] + or hashlib.sha256(Path('/etc/machine-id').read_bytes()).hexdigest()!=host['machine_id_sha256']):raise Refused() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/identity-offsite.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/identity-offsite.py new file mode 100644 index 00000000..33707e5a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/identity-offsite.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Folder-scoped Storage Share ciphertext publication and verified readback. + +Runtime gets only a dedicated read/create share token. Account administration, +deletion and age private keys have no route in this publisher. +""" +import base64 +from datetime import datetime,timezone +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +HOST='https://nx4069.your-storageshare.de' +BASE=HOST+'/public.php/webdav/' +NAME=re.compile(r'net-kingdom-identity-\d{8}T\d{6}Z-[a-f0-9]{12}\.tar\.age\Z') +LIMIT=512*1024*1024 +HEADROOM=2*1024**3 + + +class Refused(Exception):pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):raise Refused() + + +def opener():return urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect()) + + +def headers(token): + if not isinstance(token,str) or not re.fullmatch('[A-Za-z0-9]{10,128}',token):raise Refused() + return {'Authorization':'Basic '+base64.b64encode((token+':').encode()).decode(), + 'X-Requested-With':'XMLHttpRequest','Accept-Encoding':'identity'} + + +def digest(path): + with path.open('rb') as source:return hashlib.file_digest(source,'sha256').hexdigest() + + +def quota(client,token): + body=b'' + h=headers(token);h.update(Depth='0');h['Content-Type']='application/xml' + with client.open(urllib.request.Request(BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response: + if response.status!=207 or response.url!=BASE:raise Refused() + raw=response.read(1024*1024+1) + if len(raw)>1024*1024:raise Refused() + amount=ET.fromstring(raw).findtext('.//{DAV:}quota-available-bytes') or '' + if not amount.isdigit():raise Refused() + return int(amount) + + +def etag(response,url,size): + value=response.headers.get('ETag','') + if (response.status!=200 or response.url!=url or response.headers.get('Content-Length')!=str(size) + or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',value)):raise Refused() + return value + + +def readback(client,token,name,size,expected,target): + valid=NAME.fullmatch(name) or (name.endswith('.receipt.json') and NAME.fullmatch(name.removesuffix('.receipt.json')) and size<=65536) + if not valid or not 0size:raise Refused() + sha.update(block);out.write(block) + out.flush();os.fsync(out.fileno()) + if count!=size or sha.hexdigest()!=expected:raise Refused() + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response: + if etag(response,url,size)!=version:raise Refused() + return version + + +def publish(client,token,source,capture,target): + if (not NAME.fullmatch(source.name) or source.is_symlink() or source.stat().st_mode&0o077 + or source.stat().st_uid!=os.getuid() or source.stat().st_nlink!=1 or not source.is_file() + or source.stat().st_size>LIMIT or capture.get('status')!='encrypted_bundle_created' + or capture.get('helper_cleanup_verified') is not True or capture.get('temporary_plaintext_cleanup_verified') is not True + or digest(source)!=capture.get('ciphertext_sha256') or source.stat().st_size!=capture.get('ciphertext_bytes')):raise Refused() + with source.open('rb') as stream: + if stream.read(22)!=b'age-encryption.org/v1\n':raise Refused() + available=quota(client,token) + if availablebundle.LIMIT+4*1024*1024:raise adapter.Refused() + root=work/'restored';root.mkdir(mode=0o700) + manifest,_=bundle.restore(archive,root) + payload=adapter.private_payload(root,manifest,admission['manifest_sha256']) + import yaml + cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'])) + if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused() + # Public executable readback uses the daemon archive API only on the + # original image rootfs, never a tmpfs or private user-created mount. + probe=session['public_image_probe'] + try: + run(['docker','create','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE]) + binary=adapter.container_file(probe,'/keycape') + if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused() + finally: + receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}] + run(['docker','rm',probe]) + if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused() + receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified' + provision(session,payload,tls,admission['issuer']) + receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files'])) + checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'} + # Native checker consumes no service secret argument or new grant. + # Retry only bounded public readiness; no account/factor attempt. + for attempt in range(40): + try: + numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers() + adapter.check_jwks(session,checked_admission,numbers);break + except Exception: + if attempt==39:raise + time.sleep(.25) + adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False) + finally: + os.close(keyfd) + try: + if session is not None and staging is not None: + uncertain=daemon_uncertain(staging) + receipt['in_flight_daemon_request_uncertain']=uncertain + recover_obligations(session,staging,receipt) + if uncertain:raise adapter.Refused() + if reaper is not None: + reaper.terminate();reaper.wait(timeout=10) + except Exception: + receipt['status']='failed' + raise + finally: + adapter.EXECUTION=None + if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True + + +def persist_receipt(fd,receipt): + raw=(json.dumps(receipt,indent=2)+'\n').encode() + os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0) + with os.fdopen(os.dup(fd),'wb') as output: + output.write(raw);output.flush();os.fsync(output.fileno()) + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True) + args=p.parse_args();os.umask(0o077) + fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False} + try: + execution=isolate_execution() + raw=Path(args.admission).read_bytes() + if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused() + admission=json.loads(raw) + tls={} + for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)): + source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes() + execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,execution,checkpoint=lambda r:persist_receipt(fd,r)) + except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed') + finally: + receipt['finished_at']=datetime.now(timezone.utc).isoformat() + try:persist_receipt(fd,receipt) + finally:os.close(fd) + print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.') + return int(receipt['status']!='isolated_keycape_native_registration_checked') + +if __name__=='__main__': + for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/isolated-keycape.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/isolated-keycape.py new file mode 100644 index 00000000..a8e78cee --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/isolated-keycape.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +"""T18 native adapter for an owner-admitted, already restored isolated session. + +Called after identity-bundle.restore, never with an unvalidated directory. This +module does not provision TLS, grant credentials, or claim downstream acceptance. +The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. +""" +import base64 +import hashlib +import importlib.util +import json +import io +import os +import secrets +import tarfile +import time +import re +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' +LABEL = 'net-kingdom.recovery-run' + + +class Refused(Exception): + pass + + +def load_bundle(): + spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def private_payload(root, manifest, expected_manifest_sha256): + """Bind the complete validated archive before selecting KeyCape material. + + Private hashes never enter the public receipt. The digest is supplied by the + existing private archive admission, not inferred from an untrusted manifest. + """ + encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() + if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: + raise Refused() + entries = manifest['files'] + if len(entries) != len({e['path'] for e in entries}): + raise Refused() + values = {} + bundle = load_bundle() + for entry in entries: + if not bundle.allowed(entry['path'], manifest['profile']): + raise Refused() + path = root / entry['path'] + bundle.private(path) + value = path.read_bytes() + if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: + raise Refused() + values[entry['path']] = value + deployment = json.loads(values['resources/sso/deployment/keycape.json']) + main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') + if main.get('command') or main.get('args') or main.get('envFrom'): + raise Refused() + if main['image'] != IMAGE: + # Mutable-tag/historical mismatches need a separate explicit owner admission. + raise Refused() + secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] + files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) + for name in ('config.yaml', 'key.pem')} + for path, value in values.items(): + if path.startswith('data/keycape/'): + relative = path.removeprefix('data/keycape/') + files['/var/lib/keycape-policy/' + relative] = value + if '/var/lib/keycape-policy/policy.json' not in files: + raise Refused() + if 'resources/sso/secret/keycape-factor-read.json' in values: + factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data'] + files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True) + # Resolve original delivered environment, including all referenced secrets. + env = {} + for item in main.get('env', []): + if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused() + if item['name'] in env: + raise Refused() + if 'value' in item: + env[item['name']] = item['value'] + else: + ref = item['valueFrom']['secretKeyRef'] + source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] + env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() + if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': + raise Refused() + return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} + + +ENDPOINT = 'unix:///var/run/docker.sock' +EXECUTION = None + + +def docker_environment(environment=None): + # Even an admitted daemon exposed by another Unix socket is outside this + # exact session binding. Conflicting ambient routing is refused, not ignored. + for source in (os.environ, environment or {}): + if source.get('DOCKER_HOST') not in (None,'',ENDPOINT):raise Refused() + if source.get('DOCKER_CONTEXT') not in (None,'','default'):raise Refused() + if any(source.get(k) for k in ('DOCKER_TLS_VERIFY','DOCKER_CERT_PATH')):raise Refused() + env=dict(os.environ if environment is None else environment) + for key in ('DOCKER_CONTEXT','DOCKER_TLS_VERIFY','DOCKER_CERT_PATH'):env.pop(key,None) + env['DOCKER_HOST']=ENDPOINT + return env + + +def process(argv, **kwargs): + marker=None + if EXECUTION is not None and (kwargs.get('start_new_session') or kwargs.get('process_group') not in (None,-1)):raise Refused() + if argv[0]=='docker': + argv=['docker','--host',ENDPOINT,*argv[1:]] + kwargs['env']=docker_environment(kwargs.get('env')) + if EXECUTION is not None: + if (os.getpid()!=EXECUTION['pid'] or os.getsid(0)!=EXECUTION['sid'] or os.getpgrp()!=EXECUTION['pgid'] + or time.monotonic()>=EXECUTION['deadline']):raise Refused() + marker=Path(EXECUTION['staging']+'-docker-inflight') + fd=os.open(marker,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + os.close(fd) + try: + result=subprocess.run(argv,**kwargs) + return result + except BaseException: + # Parent loss leaves .docker-inflight; caught timeout/transport failures + # retain a sticky uncertainty marker even if later cleanup can run. + if marker is not None: + fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd) + raise + finally: + if marker is not None: + try:marker.unlink() + except FileNotFoundError:pass + + +def command(argv, **kwargs): + maximum = kwargs.pop('max_output', 32768) + result = process(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=kwargs.pop('timeout', 180), **kwargs) + if result.returncode or len(result.stdout) > maximum: + raise Refused() + return result.stdout + + +def inspect_session(session, worker_only=False): + """No credential environment is read by Docker inspect or returned to caller.""" + if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): + raise Refused() + names = session['containers'] + if not names or len(names) > 12 or len(set(names)) != len(names): + raise Refused() + for name in names: + if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): + raise Refused() + if session['worker'] not in names or session['issuer'] not in names: + raise Refused() + import os + contexts=json.loads(command(['docker','context','inspect'])) + endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST') or ENDPOINT] + if not all(x==ENDPOINT for x in endpoints):raise Refused() + info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) + if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: + raise Refused() + # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. + anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() + for name,spec in session.get('volumes',{}).items(): + volume=json.loads(command(['docker','volume','inspect',name])) + expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} + if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused() + for name in names: + obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}'])) + host = obj['host'] + mounts=obj['mounts'] + destinations=[m['Destination'] for m in mounts] + if len(destinations)!=len(set(destinations)):raise Refused() + for volume,expected_spec in session.get('volumes',{}).items(): + matches=[m for m in mounts if m['Destination']==expected_spec['target']] + if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker']):raise Refused() + if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') + or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') + or host.get('PidMode') or host.get('IpcMode') == 'host' + or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' + or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {}) + or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') + or host.get('CapDrop') != ['ALL'] + or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) + or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts']) + or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) + or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): + raise Refused() + if worker_only:return + if session['images'][session['issuer']] != IMAGE: + raise Refused() + # Known admitted archive has no command/args override; image default /keycape + # must be the actual issuer executable, not an alternate same-image command. + launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format', + '{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}'])) + if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}: + raise Refused() + + +def container_file(container, path): + # Docker streams one tar member into memory; never materialize private files. + raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024) + with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: + members = archive.getmembers() + if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024: + raise Refused() + return archive.extractfile(members[0]).read() + + +JWKS_PROBE = r""" +import base64,ipaddress,json,socket,ssl,sys,urllib.request +from urllib.parse import urlsplit +p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) +def require(condition): + if not condition:raise ValueError('public_jwks_binding_refused') +require(origin.scheme=='https') +resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM) +require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved)) +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):return None +opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), + urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) +def get(url): + u=urlsplit(url) + require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443)) + require(not u.username and not u.password and not u.fragment) + with opener.open(url,timeout=10) as response: + raw=response.read(1024*1024+1);require(len(raw)<=1024*1024) + return json.loads(raw) +d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer) +keys=get(d['jwks_uri'])['keys'] +def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') +# The exact admitted owner release constructs one key, kid key-1. Reject every +# extra key, including irrelevant keys: a native successful exchange can then +# only have used the original signer through the existing verifier. +require(isinstance(keys,list) and len(keys)==1) +k=keys[0] +require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256') +require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e'])) +print('{"original_public_signing_key_in_live_jwks":true}') +""" + + +def check_jwks(session, admission, public_numbers): + trust=runtime_file(session,admission['trust_path']).decode() + request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} + result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_PROBE],input=json.dumps(request).encode())) + if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() + + +RUNTIME_FILES = r""" +import base64,json,sys +from pathlib import Path +p=json.load(sys.stdin) +roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') +result={} +paths=[] +if p.get('tree'): + paths=list(Path('/var/lib/keycape-policy').rglob('*')) +else:paths=[Path(x) for x in p['paths']] +for f in paths: + if f.is_dir():continue + if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError() + result[str(f)]=base64.b64encode(f.read_bytes()).decode() + if len(result)>4096:raise ValueError() +print(json.dumps(result)) +""" + + +def runtime_files(session, paths=(), tree=False): + raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES], + input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024) + return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()} + + +def runtime_file(session,path): + return runtime_files(session,[path])[path] + + +def run_verifier(session, admission, payload): + issuer = session['issuer'] + if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() + from urllib.parse import urlsplit + import ipaddress + address = urlsplit(admission['issuer']) + if (address.scheme != 'https' or not address.hostname or address.username or address.password + or address.query or address.fragment): + raise Refused() + if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: + raise Refused() + if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']: + raise Refused() + # Capture private environment only in owner process memory, never receipt. + environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) + env = dict(item.split('=',1) for item in environment) + if len(env)!=len(environment):raise Refused() + if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused() + if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): + raise Refused() + if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused() + for name,value in payload['env'].items(): + if env.get(name) != value: + raise Refused() + files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} + def equal(): + if runtime_files(session,files)!=files:raise Refused() + durable=runtime_files(session,tree=True) + if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: + raise Refused() + equal() + # Reading YAML is data parsing only; no archived executable is evaluated. + import yaml + cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) + if cfg['issuer'] != admission['issuer']: + raise Refused() + consumer = admission['consumer'] + client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) + if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' + or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): + raise Refused() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + wrong = secrets.token_urlsafe(48) + if wrong == env[consumer['secret_env']]:raise Refused() + args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', + '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] + flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', + 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} + for name,flag in flags.items():args+=['-'+flag,consumer[name]] + # Native command reports on stderr. Do not return or print its raw response. + result = process(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) + raw = (result.stdout+result.stderr).decode() + required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', + 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', + 'granted scope carries no excess grant','predecessor secret refused'] + required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] + if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: + raise Refused() + equal() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) + return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, + 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, + 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), + 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} + + +def cleanup(session, receipt=None): + """Attempt every owned cleanup even when one obligation fails. + + Preserve exact safe container names and outcomes so the owner can recover an + uncertain/partial cleanup. An inspect/daemon failure never means absence. + """ + obligations = [] + failed = False + for name in reversed(session['containers']): + row = {'container': name, 'status': 'unverified'} + obligations.append(row) + try: + present=command(['docker','container','ls','--all','--filter','name=^/'+name+'$','--format','{{.Names}}']).decode().splitlines() + if not present: + row['status']='absence_verified' + continue + label = command(['docker', 'container', 'inspect', name, '--format', + '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() + if label != session['run_id']: + row['status'] = 'ownership_mismatch_not_removed' + failed = True + continue + # A failed remove is followed by a successful explicit inventory; + # interrupted remove may actually have completed. + try: + command(['docker', 'rm', '--force', '--volumes', name]) + except Exception: + pass + remaining = command(['docker','container','ls','--all','--filter', + 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() + if remaining: + row['status'] = 'still_present' + failed = True + else: + row['status'] = 'absence_verified' + except Exception: + failed = True + if receipt is not None: + receipt['container_cleanup_obligations'] = obligations + if failed: + raise Refused() + + + +def native_checks(root, manifest, admission, receipt, cleanup_session=True): + """Owner wrapper provides a reviewed session after existing restore pipeline. + + Admission must bind ciphertext, canonical private manifest digest, executable + and trust bytes, exact image/target list, original issuer and selected consumer. + None of these admission fields grant authority or carry a client secret. + """ + session = admission['session'] + # Validate ownership/containment before permitting either exec or cleanup. + inspect_session(session) + receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, + human_mfa_proven=False, downstream_consumer_reconnect_proven=False, + container_cleanup_verified=False) + try: + for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): + if not re.fullmatch('[a-f0-9]{64}', admission[field]): + raise Refused() + for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): + if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): + raise Refused() + payload = private_payload(root, manifest, admission['manifest_sha256']) + result = run_verifier(session, admission, payload) + required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', + 'denied_scopes_refused','original_config_registry_policy_receipts_equal', + 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} + if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): + raise Refused() + # Closed receipt allowlist: never copy arbitrary worker output. + receipt.update({k: True for k in required}) + receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] + receipt['status'] = 'isolated_keycape_native_registration_checked' + finally: + try: + if cleanup_session: + cleanup(session, receipt) + receipt['container_cleanup_verified'] = True + except Exception: + receipt['status'] = 'failed' + raise diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/isolated-lldap.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/isolated-lldap.py new file mode 100644 index 00000000..abe0865c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/isolated-lldap.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Owner-run native LLDAP restore from the verified identity bundle. + +Private inputs remain in the owner process/pipe. Decrypted staging uses Linux +tmpfs. Containers have no network, ports, host mounts or production API access. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import signal +import sqlite3 +import subprocess +import tarfile +import tempfile +import time +import tomllib +import uuid + +HERE = Path(__file__).resolve().parent + + +def load(name): + spec = importlib.util.spec_from_file_location(name, HERE / (name + '.py')) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +verify = load('verify-identity') +offline = load('retrieve-identity-offline') +bundle = verify.bundle +IMAGE = 'docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d' +CLIENT = 'ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473' +ENGINE_ID = '74f93783-6cef-43a8-a3c4-f62586cd3bc1' +ENGINE_NAME = 'bnt-lap001' + + +def command(args, **kwargs): + result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=kwargs.pop('timeout', 90), **kwargs) + if result.returncode: + raise bundle.Refused() + return result.stdout + + +def preflight(): + contexts = json.loads(command(['docker', 'context', 'inspect'])) + endpoints = [contexts[0]['Endpoints']['docker']['Host']] + if os.environ.get('DOCKER_HOST'): + endpoints.append(os.environ['DOCKER_HOST']) + if not all(url.startswith('unix://') for url in endpoints): + raise bundle.Refused() + facts = json.loads(command(['docker', 'info', '--format', + '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) + if facts['ID'] != ENGINE_ID or facts['Name'] != ENGINE_NAME or facts['OSType'] != 'linux': + raise bundle.Refused() + # /dev/shm must actually be tmpfs, not a similarly named disk directory. + mounts = Path('/proc/mounts').read_text().splitlines() + if not any(line.split()[1:3] == ['/dev/shm', 'tmpfs'] for line in mounts): + raise bundle.Refused() + images = [] + for image in (IMAGE, CLIENT): + image_id = command(['docker', 'image', 'inspect', '--format', '{{.Id}}', image]).decode().strip() + images.append({'reference': image, 'image_id': image_id}) + return {'engine_id': facts['ID'], 'engine_name': facts['Name'], 'images': images, + 'production_contacted': False, 'network_mode': 'none', 'host_ports_published': False} + + +def snapshot(path): + # Include all fields of durable identity tables, including stored password + # hashes. Only counts and the equality boolean leave this process. + result = {} + with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db: + if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: + raise bundle.Refused() + tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + for name in ('users', 'groups', 'memberships', 'user_attributes', 'group_attributes', + 'user_attribute_values', 'group_attribute_values', + 'user_attribute_schema', 'group_attribute_schema'): + if name in tables: + rows = db.execute('SELECT * FROM "' + name + '"').fetchall() + encoded = sorted(repr(row) for row in rows) + result[name] = {'count': len(rows), 'digest': hashlib.sha256('\n'.join(encoded).encode()).digest()} + if not {'users', 'groups', 'memberships'} <= result.keys(): + raise bundle.Refused() + return result + + +def configuration(root): + data = root / 'data/lldap' + existing = data / 'lldap_config.toml' + config = tomllib.loads(existing.read_text()) if existing.is_file() else {} + deployment = bundle.resource(root, 'sso', 'deployment', 'lldap') + main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'lldap') + env = {e['name']: e['value'] for e in main.get('env', []) if 'value' in e} + secret = bundle.resource(root, 'sso', 'secret', 'lldap-secrets')['data'] + jwt = base64.b64decode(secret['LLDAP_JWT_SECRET'], validate=True) + password = base64.b64decode(secret['LLDAP_LDAP_USER_PASS'], validate=True) + if not jwt or not password or b'\n' in jwt or b'\n' in password: + raise bundle.Refused() + base = env.get('LLDAP_LDAP_BASE_DN', config.get('ldap_base_dn')) + user = env.get('LLDAP_LDAP_USER_DN', config.get('ldap_user_dn', 'admin')) + if not isinstance(base, str) or not base or not isinstance(user, str): + raise bundle.Refused() + chosen = {'database_url': 'sqlite:///data/users.db?mode=rwc', + 'ldap_base_dn': base, 'ldap_user_dn': user, + 'ldap_host': '127.0.0.1', 'http_host': '127.0.0.1', + 'ldap_port': 3890, 'http_port': 17170, + 'force_ldap_user_pass_reset': False, 'force_update_private_key': False} + seed = env.get('LLDAP_KEY_SEED', config.get('key_seed')) + if seed: + if not isinstance(seed, str): + raise bundle.Refused() + chosen['key_seed'] = seed + else: + value = env.get('LLDAP_KEY_FILE', config.get('key_file', '/data/private_key')) + key = PurePosixPath(value) + if not key.is_absolute(): + key = PurePosixPath('/data') / key + if key.parent != PurePosixPath('/data') or not (data / key.name).is_file(): + raise bundle.Refused() + chosen['key_file'] = str(key) + # JSON strings are valid TOML basic strings for these service inputs. + raw = '\n'.join(k + ' = ' + json.dumps(v) for k, v in chosen.items()) + raw += '\n[smtp_options]\nenable_password_reset = false\n[ldaps_options]\nenabled = false\n' + return raw.encode(), jwt, password, base + + +def archive_files(files): + out = io.BytesIO() + with tarfile.open(fileobj=out, mode='w') as archive: + for name, value in files.items(): + if PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts: + raise bundle.Refused() + entry = tarfile.TarInfo(name) + entry.size = len(value) + entry.mode = 0o600 + entry.uid = entry.gid = 1000 + archive.addfile(entry, io.BytesIO(value)) + return out.getvalue() + + +PROBE = r''' +import contextlib,io,json,logging,socket,sys,time +logging.disable(logging.CRITICAL) +result={'status':'failed'} +stage='network_isolation' +try: + with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + from pathlib import Path + assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'} + assert len(Path('/proc/net/route').read_text().splitlines())==1 + stage='native_client_import' + from ldap3 import Server,Connection,NONE + from ldap3.utils.dn import escape_rdn + values=json.load(sys.stdin) + stage='directory_startup' + for _ in range(60): + try: + with socket.create_connection(('127.0.0.1',3890),timeout=1):pass + break + except OSError:time.sleep(.25) + else:raise RuntimeError() + dn='uid='+escape_rdn(values['user'])+',ou=people,'+values['base'] + server=Server('127.0.0.1',port=3890,get_info=NONE,connect_timeout=3) + stage='existing_user_bind' + connection=Connection(server,user=dn,password=values['password'],auto_bind=True,raise_exceptions=True) + assert connection.bound + connection.unbind() + stage='wrong_password_bind' + wrong=Connection(server,user=dn,password=values['wrong_password'],auto_bind=False,raise_exceptions=False) + refused=not wrong.bind() and wrong.result.get('result')==49 + wrong.unbind() + assert refused + result={'status':'verified','existing_user_native_ldap_bind':True,'wrong_password_refused':True, + 'only_loopback_interface_verified':True,'external_routes_absent':True} +except Exception as error:result={'status':'failed','failed_stage':stage,'failure_type':type(error).__name__} +print(json.dumps(result)) +''' + + +def restore_lldap(root, user, password, receipt): + before = snapshot(root / 'data/lldap/users.db') + config, jwt, admin_password, base = configuration(root) + name = 'nk49-lldap-' + uuid.uuid4().hex + client_name = name + '-client' + try: + command(['docker', 'run', '--detach', '--pull=never', '--name', name, + '--network', 'none', '--read-only', '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', '--user', '1000:1000', + '--memory', '256m', '--cpus', '1', '--pids-limit', '128', + '--tmpfs', '/data:rw,nosuid,nodev,noexec,size=268435456,uid=1000,gid=1000,mode=0700', + '--tmpfs', '/run/recovery:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700', + '--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000', + '--env', 'LLDAP_JWT_SECRET_FILE=/run/recovery/jwt', + '--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/recovery/admin-password', + '--entrypoint', '/bin/sh', IMAGE, '-c', 'sleep 1800']) + guard = json.loads(command(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}'])) + host = guard['host'] + if (host['NetworkMode'] != 'none' or not host['ReadonlyRootfs'] or host['Privileged'] + or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', []) + or 'no-new-privileges' not in host.get('SecurityOpt', []) + or guard['user'] != '1000:1000' + or any(mount['Type'] != 'tmpfs' for mount in guard['mounts'])): + raise bundle.Refused() + receipt['container_isolation_verified_before_private_import'] = True + payload = {p.relative_to(root / 'data/lldap').as_posix(): p.read_bytes() + for p in (root / 'data/lldap').rglob('*') if p.is_file()} + command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/data'], input=archive_files(payload)) + command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/recovery'], + input=archive_files({'lldap.toml': config, 'jwt': jwt, 'admin-password': admin_password})) + # No credential values in Docker configuration, environment or argv. + command(['docker', 'exec', '--detach', name, '/bin/sh', '-c', + 'echo $$ > /run/recovery/server.pid; exec /app/lldap run --config-file /run/recovery/lldap.toml > /dev/null 2>&1']) + raw = command(['docker', 'run', '--interactive', '--rm', '--name', client_name, '--pull=never', '--network', 'container:' + name, + '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--memory', '256m', '--cpus', '1', '--pids-limit', '64', + '--entrypoint', 'python3', CLIENT, '-c', PROBE], + input=json.dumps({'user': user, 'password': password, 'base': base, + 'wrong_password': 'nk49-wrong-' + uuid.uuid4().hex}).encode()) + result = json.loads(raw) + receipt.update(result) + if result.get('status') != 'verified': + raise bundle.Refused() + command(['docker', 'exec', name, '/bin/sh', '-c', 'kill -TERM "$(cat /run/recovery/server.pid)"']) + for _ in range(60): + running = subprocess.run(['docker', 'exec', name, '/bin/sh', '-c', + 'kill -0 "$(cat /run/recovery/server.pid)"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + if running.returncode: + break + time.sleep(.1) + else: + raise bundle.Refused() + raw = command(['docker', 'exec', name, '/bin/sh', '-c', + 'test ! -s /data/users.db-wal && cat /data/users.db']) + after_path = root / 'post-restore-users.db' + after_path.write_bytes(raw) + if snapshot(after_path) != before: + raise bundle.Refused() + receipt.update(directory_identity_rows_preserved=True, + directory_comparison_scope=sorted(before), + directory_table_counts={k: v['count'] for k, v in before.items()}) + finally: + # A failed create can still have created a container (e.g. lost reply). + for owned in (client_name, name): + found = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=15) + if found.returncode == 0: + command(['docker', 'rm', '--force', '--volumes', owned]) + absent = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=15) + command(['docker', 'info', '--format', '{{.ID}}']) + if absent.returncode == 0: + raise bundle.Refused() + receipt['container_cleanup_verified'] = True + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--transfer-receipt', required=True) + parser.add_argument('--receipt', required=True) + parser.add_argument('--user', required=True) + args = parser.parse_args() + os.umask(0o077) + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + result = {'schema': 'netkingdom.isolated-lldap-recovery.v1', 'status': 'failed', + 'production_contacted': False, 'secret_values_exported': False, + 'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False, + 'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} + keyfd = None + work = None + stage = 'target_preflight' + try: + result['target'] = preflight() + stage = 'input_validation' + if not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', args.user): + raise bundle.Refused() + transfer = json.loads(Path(args.transfer_receipt).read_text()) + offline.validate(transfer) + if transfer.get('independent_owner_retrieval_verified') is not True: + raise bundle.Refused() + path = Path(transfer['fetched_path']) + bundle.private(path, max_bytes=bundle.LIMIT) + if path.stat().st_size != transfer['ciphertext_bytes'] or offline.offsite.digest(path) != transfer['ciphertext_sha256']: + raise bundle.Refused() + keyfd = verify.custody.private_key_fd() + derived = command(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip() + if derived != bundle.profile()['public_recipient'].encode(): + raise bundle.Refused() + stage = 'private_existing_password_prompt' + password = offline.private_prompt('Existing account password for the isolated LLDAP check: ') + with tempfile.TemporaryDirectory(prefix='nk49-service-recovery-', dir='/dev/shm') as temporary: + work = Path(temporary) + archive = work / 'archive.tar' + stage = 'archive_decryption' + with archive.open('xb') as output: + proc = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)], + stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180) + if proc.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024: + raise bundle.Refused() + restored = work / 'restored' + restored.mkdir(mode=0o700) + stage = 'archive_validation' + manifest, _ = bundle.restore(archive, restored) + result.update(ciphertext_sha256=transfer['ciphertext_sha256'], + manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True) + stage = 'native_directory_check' + restore_lldap(restored, args.user, password, result) + password = None + if work.exists(): + raise bundle.Refused() + result.update(status='isolated_lldap_verified', temporary_plaintext_cleanup_verified=True) + except (Exception, KeyboardInterrupt): + result.update(status='failed', error='isolated_lldap_check_failed_private_diagnostics_suppressed') + result.setdefault('failed_stage', stage) + finally: + if keyfd is not None: + os.close(keyfd) + if work is not None and not work.exists(): + result['temporary_plaintext_cleanup_verified'] = True + result['finished_at'] = datetime.now(timezone.utc).isoformat() + with os.fdopen(fd, 'w') as output: + json.dump(result, output, indent=2) + output.write('\n') + success = result['status'] == 'isolated_lldap_verified' + print('OK: restored LLDAP existing-user bind, wrong-password refusal, directory preservation and cleanup passed.' + if success else 'ERROR: isolated LLDAP recovery refused; review the safe receipt. No production change.') + return 0 if success else 1 + + +if __name__ == '__main__': + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, offline.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py new file mode 100644 index 00000000..a6173235 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Download one reviewed ciphertext using privately entered provider access. + +No OpenBao, SSO, Kubernetes, credential discovery, provider writes or age key. +Run by the operator in their own terminal, never through a recorded agent PTY. +""" +import argparse +import base64 +from datetime import datetime, timezone +import getpass +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import signal +import sys +import tempfile +import urllib.request +import urllib.error +import warnings + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('offsite', HERE / 'identity-offsite.py') +offsite = importlib.util.module_from_spec(spec) +spec.loader.exec_module(offsite) +ACCOUNT_BASE = offsite.HOST + '/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/' + + +def private_prompt(label): + # getpass opens the terminal with os.open, which does not require seeking. + # Reject redirected input and turn its echoing fallback warning into an + # exception before it can read a credential without echo suppression. + if not sys.stdin.isatty() or not sys.stderr.isatty(): + raise offsite.Refused() + with warnings.catch_warnings(): + warnings.simplefilter('error', getpass.GetPassWarning) + return getpass.getpass(label) + + +def failure_metadata(error): + # No exception messages, response bodies, URLs or headers enter receipts. + if isinstance(error, urllib.error.HTTPError): + return {'failure_kind': 'provider_http_error', 'http_status': int(error.code)} + if isinstance(error, urllib.error.URLError): + return {'failure_kind': 'provider_transport_error'} + if isinstance(error, getpass.GetPassWarning): + return {'failure_kind': 'terminal_echo_suppression_unavailable'} + if isinstance(error, offsite.Refused): + return {'failure_kind': 'scope_or_integrity_refusal'} + return {'failure_kind': 'local_operation_failed'} + + +def validate(record): + if (record.get('schema') != 'netkingdom.identity-offsite.v1' + or record.get('status') != 'readback_verified' + or record.get('provider') != 'Storage Share' + or record.get('folder') != 'Backup/railiance-backups/net-kingdom-identity' + or not offsite.NAME.fullmatch(record.get('artifact', '')) + or not re.fullmatch('[a-f0-9]{64}', record.get('ciphertext_sha256', '')) + or type(record.get('ciphertext_bytes')) is not int + or not 0 < record['ciphertext_bytes'] <= offsite.LIMIT + or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"', record.get('etag', ''))): + raise offsite.Refused() + + +def retrieve(record, secret, mode, client=None): + validate(record) + if mode not in {'account', 'folder-share'} or not secret or any(c in secret for c in '\r\n\x00'): + raise offsite.Refused() + auth = offsite.headers(secret) if mode == 'folder-share' else { + 'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(), + 'Accept-Encoding': 'identity'} + url = (offsite.BASE if mode == 'folder-share' else ACCOUNT_BASE) + record['artifact'] + client = client or offsite.opener() + directory = None + complete = False + try: + with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + directory = Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-', dir='/tmp')) + target = directory / record['artifact'] + auth['If-Match'] = record['etag'] + count = 0 + digest = hashlib.sha256() + with client.open(urllib.request.Request(url, headers=auth), timeout=180) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as output: + while block := response.read(1024 * 1024): + count += len(block) + if count > record['ciphertext_bytes']: + raise offsite.Refused() + digest.update(block) + output.write(block) + output.flush() + os.fsync(output.fileno()) + if count != record['ciphertext_bytes'] or digest.hexdigest() != record['ciphertext_sha256']: + raise offsite.Refused() + with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + complete = True + # Closed allowlist: never copy arbitrary source-receipt fields to output. + result = {k: record[k] for k in ('schema', 'provider', 'folder', 'artifact', + 'ciphertext_bytes', 'ciphertext_sha256', 'etag')} + result.update(status='readback_verified', fetched_path=str(target), + independent_owner_retrieval_verified=True, + credential_source='independently_held_provider_access', + provider_auth_mode=mode, openbao_contacted=False, sso_contacted=False, + provider_writes_performed=False, age_private_key_used=False, + secret_values_exported=False, object_unchanged_during_transfer=True, + downloaded_at=datetime.now(timezone.utc).isoformat()) + return result + finally: + if directory is not None and not complete: + shutil.rmtree(directory) + + +def interrupted(signum, frame): + raise InterruptedError() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source-receipt', required=True) + parser.add_argument('--receipt', required=True) + parser.add_argument('--auth', choices=('account', 'folder-share'), default='account') + args = parser.parse_args() + os.umask(0o077) + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + result = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'} + stage = 'source_receipt_validation' + try: + record = json.loads(Path(args.source_receipt).read_text()) + validate(record) + # getpass must never fall back to echoed stdin (e.g. a captured agent tool). + stage = 'private_provider_prompt' + secret = private_prompt('Storage Share Backup app password or exact folder-share token: ') + try: + stage = 'provider_download' + result = retrieve(record, secret, args.auth) + finally: + secret = None + except (Exception, KeyboardInterrupt) as error: + result.update(failed_stage=stage, **failure_metadata(error)) + finally: + with os.fdopen(fd, 'w') as output: + json.dump(result, output, indent=2) + output.write('\n') + ok = result['status'] == 'readback_verified' + print('OK: ciphertext retrieved without OpenBao or SSO; ETag, size and SHA256 verified.' if ok + else 'ERROR: retrieval refused; private diagnostics suppressed. Use a new receipt path to retry.') + return 0 if ok else 1 + + +if __name__ == '__main__': + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, interrupted) + sys.exit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/retrieve-identity.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/retrieve-identity.py new file mode 100644 index 00000000..ed386d9a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/retrieve-identity.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Silent attended owner retrieval of an existing identity ciphertext. + +Uses Backup-account recovery custody only in memory; no private age key, upload, +share, grant, deletion or production restore operation. +""" +import argparse +import base64 +from datetime import datetime,timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import tempfile +import urllib.request + +HERE=Path(__file__).resolve().parent + + +def load(name): + spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py')) + m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m + + +inventory=load('inventory-storage-share');offsite=load('identity-offsite') + + +def retrieve(record,out): + if (record.get('schema')!='netkingdom.identity-offsite.v1' or record.get('status')!='readback_verified' + or not offsite.NAME.fullmatch(record.get('artifact','')) or not re.fullmatch('[a-f0-9]{64}',record.get('ciphertext_sha256','')) + or not 0record['ciphertext_bytes']:raise offsite.Refused() + digest.update(block);output.write(block) + if count!=record['ciphertext_bytes'] or digest.hexdigest()!=record['ciphertext_sha256']:raise offsite.Refused() + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response: + if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused() + out.update(record);out.update(status='readback_verified',fetched_path=str(target), + independent_owner_retrieval_verified=True,caller_entity_id=identity.get('entity_id'), + age_private_key_used=False,secret_values_exported=False,downloaded_at=datetime.now(timezone.utc).isoformat()) + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--source-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077);fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} + try:retrieve(json.loads(Path(a.source_receipt).read_text()),r) + except Exception:r.update(status='failed',error='contained_owner_retrieval_failed') + finally: + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh new file mode 100644 index 00000000..74bc6e9f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Candidate owner launcher. Review and native fixture proof required before use. +set +x +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd -- "$ROOT" +umask 077 +[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; } +[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; } +for index in 1 2 4 5 6; do + [[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; } +done +[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; } +for tool in python3 age age-keygen docker; do + command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; } +done +python3 - <<'PY' +import importlib.util +from pathlib import Path +p=Path('tools/bootstrap-recovery/isolated-keycape-session.py') +spec=importlib.util.spec_from_file_location('session',p) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +try: + m.local.preflight() + m.run(['docker','image','inspect',m.adapter.IMAGE]) +except Exception: + raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.') +print('OK: fixed local target and cached pinned images available.') +PY +NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)" +printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN" +printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.' +printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.' +printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.' +bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ + --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \ + --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \ + --tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json" +printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN" diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/verify-identity.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/verify-identity.py new file mode 100644 index 00000000..11bfec14 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tools/bootstrap-recovery/verify-identity.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Attended independent-key recovery of the new data-bearing off-host bundle.""" +import argparse +from datetime import datetime,timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import uuid + +HERE=Path(__file__).resolve().parent + + +def load(name,path=None): + spec=importlib.util.spec_from_file_location(name,path or HERE/(name+'.py')) + m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m + + +bundle=load('identity-bundle');pg=load('check-privacyidea-database') +custody=load('sops_private',HERE.parents[1]/'sso-mfa/bootstrap/sops-private.py') + + +def verify(transfer,keyfd,receipt): + if (transfer.get('schema')!='netkingdom.identity-offsite.v1' or transfer.get('status')!='readback_verified' + or transfer.get('independent_owner_retrieval_verified') is not True + or transfer.get('ciphertext_bytes',0)>bundle.LIMIT):raise bundle.Refused() + path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT) + with path.open('rb') as source:digest=hashlib.file_digest(source,'sha256').hexdigest() + if path.stat().st_size!=transfer['ciphertext_bytes'] or digest!=transfer['ciphertext_sha256']:raise bundle.Refused() + derived=pg.run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip() + if derived!=bundle.profile()['public_recipient'].encode():raise bundle.Refused() + receipt.update(ciphertext_sha256=digest,artifact=transfer['artifact'],registered_recipient_match=True) + with tempfile.TemporaryDirectory(prefix='nk49-identity-restore-',dir='/tmp') as temporary: + work=Path(temporary);env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary} + wrong=work/'wrong.key';pg.run(['age-keygen','-o',str(wrong)],env=env) + negative=subprocess.run(['age','-d','-i',str(wrong),str(path)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env,timeout=120) + if negative.returncode==0:raise bundle.Refused() + receipt['wrong_key_refused']=True + plain=work/'archive.tar' + with plain.open('xb') as output: + result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output, + stderr=subprocess.DEVNULL,env=env,pass_fds=(keyfd,),timeout=180) + if result.returncode or plain.stat().st_size>bundle.LIMIT+4*1024*1024:raise bundle.Refused() + restored=work/'restored';restored.mkdir(mode=0o700) + manifest,checks=bundle.restore(plain,restored) + receipt.update(checks=checks,manifest_file_count=len(manifest['files']),all_manifest_hashes_verified=True, + profile_sha256=manifest['profile_sha256']) + container='nk49-identity-db-'+uuid.uuid4().hex + try: + receipt['database_restore']=pg.restore(restored/'databases/privacyidea.dump',container) + finally: + pg.run(['docker','rm','--force','--volumes',container]) + absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=15) + pg.run(['docker','info','--format','{{.ServerVersion}}']) + if absent.returncode==0:raise bundle.Refused() + receipt['container_cleanup_verified']=True + if work.exists():raise bundle.Refused() + receipt.update(temporary_plaintext_cleanup_verified=True,status='identity_archive_verified', + full_service_authentication_proven=False,existing_token_decryption_proven=False,audit_signature_continuity_proven=False) + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077) + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.identity-recovery.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(), + 'production_contacted':False,'secret_values_exported':False,'container_cleanup_verified':False, + 'temporary_plaintext_cleanup_verified':False,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} + keyfd=None + try: + transfer=json.loads(Path(a.transfer_receipt).read_text());keyfd=custody.private_key_fd() + verify(transfer,keyfd,r) + except Exception:r.update(status='failed',error='identity_recovery_failed_private_diagnostics_suppressed') + finally: + if keyfd is not None:os.close(keyfd) + r['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + if r['status']=='identity_archive_verified': + print('OK: off-host identity bundle decrypted; complete manifest, key pairs, SQLite integrity and PostgreSQL restore passed; disposable data removed. Service authentication remains separate acceptance.') + return 0 + print('ERROR: identity recovery failed; review safe receipt. Private diagnostics suppressed.');return 1 + + +if __name__=='__main__': + for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/FIXTURE-R5.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/FIXTURE-R5.md new file mode 100644 index 00000000..4410b943 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/FIXTURE-R5.md @@ -0,0 +1,92 @@ +# Scoped native fixture proposal, revision5 — prepared, not executed + +Review source, then start with the single positive case. Neither command below +was executed. Default `--plan` has been inspected and performs no Docker calls, +key generation or private material creation. + +Frozen fixture manifest SHA256: `6a4e3f47fc9a647d813dfbe75851d1ef62ebac7cfc7a7818b587ad56bf919c7a`. + +```sh +python3 /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/run_isolated_keycape_native_fixture.py --plan +``` + +Proposed **positive-only** command, after independent review: + +```sh +python3 /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/run_isolated_keycape_native_fixture.py --execute-reviewed-fixture --expected-manifest-sha256 6a4e3f47fc9a647d813dfbe75851d1ef62ebac7cfc7a7818b587ad56bf919c7a --case positive +``` + +Separately proposed bounded extended cases, after the positive result and review: + +```sh +python3 /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/run_isolated_keycape_native_fixture.py --execute-reviewed-fixture --expected-manifest-sha256 6a4e3f47fc9a647d813dfbe75851d1ef62ebac7cfc7a7818b587ad56bf919c7a --case all +``` + +The runner requires every source/dependency hash in its manifest to match before +preflight, and binds `unix:///var/run/docker.sock` to engine +`74f93783-6cef-43a8-a3c4-f62586cd3bc1`, `bnt-lap001`, Linux. Images are the existing +exact KeyCape `cdec9dd7…` and privacyIDEA helper `af7841ad…` references listed in +the manifest. No pulls/builds/pushes or production APIs/routes are permitted. + +It reuses `test_identity_bundle.stage` unchanged to create the complete format/ +integrity archive skeleton, then configures only synthetic KeyCape registrations, +original synthetic signing key, valid retained policy/history and lockfile, +generated synthetic factor-reader delivery, fresh synthetic age recipient and +fresh self-signed trusted certificate for `https://nk49-fixture.invalid`. Existing +archive sealing/restore and the candidate `execute()` perform the actual checks. +The other service skeletons are format fixtures, not native PI/LLDAP/Authelia +recoveries; no acceptance for those lanes follows. No actual age/TLS/client key, +provider account, existing user or retained factor is requested. + +Resources are sequential and exact-label-owned: at most two live service +containers (512MiB/oneCPU each), four 32MiB local-driver tmpfs volumes and one +short-lived stopped public executable-image probe. Container/volume names are +`nk49-kc--…`, labeled +`net-kingdom.recovery-run=`; names are recorded in the candidate's +pre-decrypt checkpoint. The initial fixture public-image readback probe is also +removed in a finally block and never starts. Host fixture materials are private +under `/dev/shm/nk49-keycape-fixture-…`; candidate staging has its separate exact +registered inode obligation. Safe case receipts and cleanup inventories are +retained in `/tmp/nk49-keycape-native-fixture-review-…/result.json`. + +The candidate itself performs shared-tmpfs import, default original-image +`/keycape` launch, loopback HTTPS transport, existing native `verify-client`, +byte/tree comparisons and quiescent container/volume/plaintext cleanup. Only the +policy mount is writable because owner `policy.Open` requires its retained lock +O_RDWR; configuration/signing key, factor and TLS mounts remain readonly to the +issuer. No browser/MFA/upstream readiness is imitated. + +**Positive oracle:** the actual issuer-container native command must succeed for +the original fixture hostname/trust, original single `key-1` signer and selected +fixture registration; explicit wrong secret/excess scopes must be denied; all +original durable bytes and receipts must remain identical; every exact cleanup +obligation must pass. A helper-only DNS/JWKS success is insufficient. If Docker +container-network sharing does not supply issuer hostname resolution, this case +fails; the runner does not rewrite issuer or bypass trust to hide it. + +**Extended oracle:** hard-parent-loss pauses only after candidate provision has +imported synthetic originals and launched the original process, then the driver +SIGKILLs the exact checkpointed owner via pidfd. Deadline similarly pauses after +import with a tightened20-second source execution deadline. The independent +candidate reaper must quiesce that fresh invocation and children before exact +cleanup. A150-second driver ceiling per case is an outer observation bound, not +a reason to claim cleanup on timeout. Other owner execution deadlines are120s. + +The `nonzero-after-accept` hook runs the real first Docker service-container +creation, then replaces its successful CLI response with returncode1. It changes +no recovery implementation. The candidate must retain sticky daemon uncertainty, +refuse recovery success and report `bounded_cleanup_uncertain` even if all named +resources subsequently read absent. Every generated name is in the candidate's +registered cleanup inventory before these operations. + +On any unexpected outcome, stop the fixture and retain the safe result/checkpoint +and reaper outcome. Do not retry uncertainty as success or delete by wildcard. +Manual recovery is restricted to the exact recorded names after checking their +run label and exact tmpfs driver/options, using the fixed explicit endpoint; +plaintext cleanup must use its registered device/inode/owner guards. The source +reaper remains responsible for bounded recovery after owner loss. + +No execution or integration is approved by this proposal.67 unit/synthetic tests +pass; two use actual disposable local processes, while all Docker/native service +outcomes remain untested. Frozen revision4 retains the original counterexample +and its source bytes. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/README.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/README.md new file mode 100644 index 00000000..7f47353d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/README.md @@ -0,0 +1,184 @@ +# T18 KeyCape recovery adapter candidate, revision 5 + +**Pending root review and native fixture proof. T18 remains open.** Source and +67 passing synthetic tests are prepared in `/tmp/nk49-keycape-adapter-next` and +copied here. No canonical owner file, credential, actual recovery, Docker run, +production route or secret-bearing archive was touched. `candidate.patch` adds +five files to NetKingdom; exact digests/check provenance are in `preparation.json`. + +Files: + +- `isolated-keycape.py`: archive/source/containment/native acceptance checks. +- `isolated-keycape-session.py`: existing-pipeline decryption, private import, + isolated original-image session, HTTPS transport, checks and cleanup. +- `run-isolated-keycape.sh`: attended owner launcher through the existing hidden + custody helper, with target preflight before the backup-key prompt. +- Two focused test files: check-stage negatives and session/cleanup ordering. + +The wrapper uses existing `isolated-lldap`, offline transfer validation, +`verify-identity.custody` and `identity-bundle.restore` APIs. It checks the exact +independently retrieved ciphertext length/digest, custody recipient, complete +archive manifest and original immutable archived KeyCape image. Decryption and +archive restore remain on `/dev/shm`. The private manifest digest comes from +owner admission; it is never emitted publicly. Archive commands/args/envFrom and +non-KeyCape environment overrides are refused. Runtime environment permits only +original KeyCape deliveries, PATH/HOME/HOSTNAME and the exact admitted +SSL_CERT_FILE; duplicate or additional security environment (including +SSL_CERT_DIR/GODEBUG/PYTHONOPTIMIZE/proxy overrides) is refused. Original config/complete static +registry, signing key, policy/receipt tree, factor delivery and original delivered +environment are preserved. Secrets reach Docker by environment **names** only, +with values in the owner subprocess environment, never argv or printed output. + +The original KeyCape image is distroless. Docker's documented +[cp corner cases](https://docs.docker.com/reference/cli/docker/container/cp/#corner-cases) +exclude tmpfs and user-created mounts. Consequently the candidate shares exactly +four Docker **local-driver tmpfs** volumes between the existing pinned Python +helper and original KeyCape issuer. Driver, scope, exact options, owner label, +source, target and read/write flags are inspected before private import. There +are no host directory mounts, disk volumes, new image, JWT verifier or execution +service. The helper's private import and comparison occur inside those exact +mounts; daemon `cp` is used only for public `/keycape` executable readback from +the original image root filesystem. This volume extension still needs native +pinned-image fixture verification before approval. + +Both containers use the fixed local Docker engine, no network interfaces beyond +loopback, readonly root, nonroot UID, no capabilities/devices/privilege/host +namespaces or unadmitted security options. Peers share the exact network-none +anchor. Only namespace-local unprivileged-port sysctl `0` is admitted so the +existing nonroot Python helper can present original HTTPS on loopback443 and +forward to the original KeyCape process on loopback8080. The issuer uses its exact +original image default `/keycape` launch: Entrypoint/Cmd and actual Path/Args are +inspected; the original file/environment bytes and executable digest are bound. +Production dependency addresses remain unreachable. No readiness, LDAP, +Authelia, MFA, tenant-engine or notification endpoint is imitated. A selected +service registration can be checked without claiming `/readyz` passed. + +The owner supplies an existing trusted issuer certificate/private key and trust +bundle through private regular files. No certificate, CA, signing key, client +registration or credential is generated/reissued. Original HTTPS hostname, trust +and same-origin discovery/JWKS are retained, without redirect/proxy/insecure +bypass. The public JWKS probe requires exactly the original owner's single RSA +signing key with `use:sig`, `alg:RS256`, `kid:key-1`, matching the original key's +public numbers. It checks before and after the native exchange. Every security +condition uses explicit refusal, including when Python optimization is enabled. +This probe only compares public discovery/JWKS data; JWT/exchange/claim/scope and +wrong-secret verification exclusively use shipped `/keycape verify-client`. + +The negative check supplies a generated unrelated random secret through the +existing `-previous-secret-env` refusal mechanism. The bounded receipt says +**wrong unrelated secret refused**, never rotation. An absent exact PASS, +nonzero exit, TLS/discovery error, malformed output or any FAIL refuses evidence. +All durable private files and the full receipt tree are compared before/after; +extra receipt files refuse continuity. Public receipts expose only booleans, +public signing SPKI fingerprint, ciphertext/source hashes and exact safe cleanup +obligations. No config, env value, private digest, credential, JWT, response body, +subject or client ID is copied into receipts. + +Normal/failure cleanup attempts every safely owned container and tmpfs volume, +records exact verified/uncertain obligations and refuses success if any removal +is uncertain. Before the first plaintext write, the wrapper registers the exact +private `/dev/shm` directory inode/device/owner/mode and every future container, +volume and public-image-probe obligation; it requires a reaper ACK and fsyncs a +safe receipt checkpoint. Plaintext removal uses directory FDs and refuses +replacement inode, owner mismatch, symlink, hardlink or special-file traversal. +Docker failure does not skip this separate plaintext obligation. + +Revision4 creates a **fresh exclusive execution session/process group** before +reading TLS or custody inputs. It never reuses a shell/custody/user group. The +independent reaper receives only the exact parent pidfd, public identifiers and +fixed endpoint, with no custody descriptor or service environment. Parent loss +or the 1800-second deadline first quiesces the owner invocation and its children: +parent identity is bound by pidfd; each remaining same-SID/PGID/UID member is +compared by stable process start time and signaled through its own pidfd. Unknown +membership, PID reuse or uninterruptible survivors refuses cleanup. No broad +user/inherited-group kill is used. Only after no live creator remains may the +reaper remove resources. Every Docker call also refuses an expired or mismatched +execution binding; host subprocesses cannot request a new session/group escape. + +A CLI can die after submitting a daemon request. Public exact sibling +`-docker-inflight`/`-docker-uncertain` markers survive plaintext removal, so even +clear later resource readback cannot turn an in-flight request into proven +cleanup. The reaper writes an exclusive bounded `-reaper.json` outcome next to +the registered staging directory; its exact path is in the pre-decrypt receipt. +Normal verified cleanup cancels the reaper. These are scoped invocation processes, +not standing jobs. This revision's real disposable process tests prove the +local shutdown/quiescence behavior only; Docker tmpfs/import/issuer behavior +still requires independent native fixture proof. + +## Exact owner inputs and invocation, after approval/native fixture proof + +The launcher takes six paths/public arguments: + +``` +bash tools/bootstrap-recovery/run-isolated-keycape.sh \ + VERIFIED_TRANSFER_RECEIPT PRIVATE_ADMISSION ADMISSION_SHA256 \ + PRIVATE_EXISTING_TLS_CERT PRIVATE_EXISTING_TLS_KEY PRIVATE_ORIGINAL_TRUST +``` + +Do not run this candidate until root review and synthetic pinned-image runtime +proof admit the exact source. It prompts only for the independently held age key +using the existing custody helper. Current client credentials come only from the +original encrypted archive's already delivered registration/environment; no key, +password, factor or TLS private input is passed to the agent. + +The hash-bound owner admission JSON has `ciphertext_sha256`, `manifest_sha256`, +`verifier_sha256` (original image `/keycape` binary), `trust_sha256`, +`tls_cert_sha256`, original `issuer`, and `consumer` containing existing exact +`client_id`, `secret_env`, `audience`, `scope`, `subject`, `tenant`, `roles`, +`deny_scope`. Private `manifest_sha256` is SHA256 of +`json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode()` from the +admitted original archive. The wrapper checks it against the validated manifest, +not an arbitrary directory. Trust path/session names are derived internally; +operator input cannot select a remote Docker engine or arbitrary container set. +The selected original issuer configuration must use HTTPS443 and local service +port8080, as the admitted owner release does. Other configurations fail closed +and need explicit source review, rather than silent rewrite. + +## Remaining proof and retained review findings + +The 67 tests use synthetic data and mocked Docker/native outcomes. They exercise +archive integrity/symlink/lineage drift, optimizer-safe JWKS wrong-key/extra-key/ +wrong-kid/cross-origin/DNS negatives, default launch binding, capabilities/devices/ +namespace/security overrides, transport-vs-wrong-secret distinction, private +import ordering, secret-free argv, shared-tmpfs planning, and partial/uncertain +cleanup. Two additional real disposable process tests exercise only synthetic owner/ +grandchild shutdown and hard parent loss, without Docker. The suite does +**not** prove the actual pinned image supports the complete +session or that the selected current credential/factor works. + +Next: root review; native disposable pinned-image fixture with original hostname +and a synthetic trusted TLS pair; positive/negative service behavior and hard +interruption cleanup; then an admitted owner-run actual recovery. Actual T18 +still needs selected downstream-consumer reconnect and owner acceptance. Human/ +browser flow uses the existing `keycape login`; retained-factor MFA stays T19. +Both remain explicitly unproven in the receipt. + +Prior exact source snapshots are retained under `revisions/r1/`, `revisions/r2/` +and `revisions/r3/`; frozen test roots/closure manifests accompany R3 and R4. Root's findings were added +as regression tests: added caps/devices/security exceptions; cleanup failure +incorrectly preserving success or abandoning later obligations; Python assert +optimization; extra JWKS signer ambiguity; same-image alternate launch; and +unsupported private tmpfs daemon-copy assumptions. Revision4 addresses these in source, plus the later lifecycle and target +findings. See `LIFECYCLE-R4.md` for the remaining native fixture oracle. No root approval or native acceptance is fabricated. + +Knowledge orientation: emergent-security0.2.6, digest +`8d1d6a9d4d07fec7b619618de7dfe2254c1790f85f4198c7fe19db9751c02`, +withheld/changed-source status; current owner AGENTS/companion/recovery contract +and verifier source were reviewed. Supervised assignment; no autopilot or +privileged execution claimed. + +## Revision5 fixture proposal + +Root reproduced Docker returncode1 after daemon acceptance; R5 records sticky +uncertainty for nonzero results before clearing the inflight marker. A regression +retains that counterexample. Public readiness failures are closed false data, +while a failing Docker CLI remains uncertain. Source review also found that +`policy.Open` requires an O_RDWR lock: only the policy tmpfs is writable in the +issuer, with exact original policy/history/lock bytes/tree still compared. + +`FIXTURE-R5.md` contains concrete positive-first and optional extended commands, +resource limits, cleanup and oracles. The hash-bound runner invokes the candidate +with the existing complete synthetic archive fixture factory; it does not accept +real custody or identity input. Its default plan was inspected without Docker. +67 synthetic tests pass, including the earlier two real disposable process tests; +no native Docker compatibility or owner acceptance is claimed. R4 remains frozen. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/sso-mfa/bootstrap/sops-private.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/sso-mfa/bootstrap/sops-private.py new file mode 100644 index 00000000..21c20b2e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/sso-mfa/bootstrap/sops-private.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Age-only SOPS recovery to a new private file; no plaintext process output.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import uuid + +ROOT = Path(__file__).resolve().parents[2] +RECIPIENT = re.compile(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}\Z') + + +class Refused(Exception): + pass + + +def public_recipient(): + lines = (ROOT / 'keys/age.pub').read_text().splitlines() + values = [line.strip() for line in lines if line.strip() and not line.startswith('#')] + if len(values) != 1 or not RECIPIENT.fullmatch(values[0]): + raise Refused('registered public recipient is invalid') + result = subprocess.run(['age', '-r', values[0]], input=b'', stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=10) + if result.returncode: + raise Refused('registered public recipient is invalid') + return values[0] + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused('duplicate encrypted document key') + result[key] = value + return result + + +def parse_document(raw, suffix): + if suffix == '.json': + data = json.loads(raw, object_pairs_hook=unique_object) + kind = 'json' + elif suffix in {'.yaml', '.yml'}: + import yaml + class UniqueLoader(yaml.SafeLoader): + def construct_mapping(self, node, deep=False): + self.flatten_mapping(node) + return unique_object((self.construct_object(key, deep=deep), + self.construct_object(value, deep=deep)) + for key, value in node.value) + try: + data = yaml.load(raw, Loader=UniqueLoader) + except yaml.YAMLError: + raise Refused('invalid encrypted YAML input') from None + kind = 'yaml' + else: + raise Refused('age recovery supports SOPS JSON/YAML input') + return data, kind + + +def age_document(source, recipient, *, raw=None): + # Inspect ciphertext metadata before giving a credential to any subprocess. + # This repository's admitted SOPS profile is age-only, not arbitrary cloud + # KMS, key services, SSH identities or executable age plugins. + if raw is None: + with source.open("rb") as encrypted: + raw = encrypted.read(8 * 1024 * 1024 + 1) + if len(raw) > 8 * 1024 * 1024: + raise Refused('encrypted input is too large') + data, kind = parse_document(raw, source.suffix) + metadata = data.get('sops') if isinstance(data, dict) else None + if not isinstance(metadata, dict): + raise Refused('SOPS metadata is missing') + known = {'age', 'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp', + 'key_groups', 'shamir_threshold', 'lastmodified', 'mac', 'version', + 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', + 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', + 'mac_only_encrypted'} + if set(metadata) - known or metadata.get('key_groups'): + raise Refused('unsupported SOPS custody profile') + if any(metadata.get(key) for key in ('kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp')): + raise Refused('recovery requires age-only metadata') + recipients = metadata.get('age') + if not isinstance(recipients, list) or not recipients: + raise Refused('age recipient metadata is missing') + if any(not isinstance(row, dict) or not RECIPIENT.fullmatch(str(row.get('recipient', ''))) + for row in recipients): + raise Refused('unsupported age recipient') + if recipient not in [row['recipient'] for row in recipients]: + raise Refused('document does not include the registered recipient') + return raw, kind + + +def private_key_fd(): + name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '') + if not name: + raise Refused('explicit temporary custody key required') + path = Path(os.path.abspath(name)) + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('custody key path must not contain symlinks') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16384: + os.close(fd) + raise Refused('custody key must be an owned private regular file') + return fd + + +def decrypt(recipient): + source_name = os.environ.get('NK_SOPS_FILE', '') + output_name = os.environ.get('NK_SOPS_OUTPUT', '') + if not source_name or not output_name: + raise Refused('supply FILE and OUTPUT; plaintext stdout is disabled') + source = Path(source_name).resolve(strict=True) + output = Path(output_name).absolute() + parent = output.parent.resolve(strict=True) + if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() + or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir()) + for p in [parent, *parent.parents]): + raise Refused('plaintext output must be outside Git worktrees') + raw, kind = age_document(source, recipient) + binary = shutil.which('sops') + if binary is None: + raise Refused('sops executable is unavailable') + directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + temporary = '.netkingdom-sops-' + uuid.uuid4().hex + keyfd = None + try: + info = os.fstat(directory) + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Refused('OUTPUT requires an owned private parent directory') + try: + os.stat(output.name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise Refused('OUTPUT already exists') + keyfd = private_key_fd() + derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + pass_fds=(keyfd,), timeout=10) + if derived.returncode or derived.stdout.strip() != recipient.encode(): + raise Refused('custody key does not match the registered recipient') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + with os.fdopen(fd, 'wb') as plain, tempfile.TemporaryDirectory(prefix='netkingdom-sops-home-') as isolated: + # HOME/XDG isolation matters: SOPS loads ambient keys in addition to + # an explicit key file. No inline keys, SSH keys, key commands, + # proxies or remote keyservice configuration are inherited. + env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated, + 'XDG_CONFIG_HOME': isolated, 'GNUPGHOME': isolated, + 'SOPS_AGE_KEY_FILE': f'/proc/self/fd/{keyfd}', + 'AWS_EC2_METADATA_DISABLED': 'true'} + result = subprocess.run([binary, '--decrypt', '--input-type', kind, + '--output-type', kind, '/dev/stdin'], + input=raw, stdout=plain, stderr=subprocess.DEVNULL, + env=env, cwd=isolated, pass_fds=(keyfd,), timeout=30) + if result.returncode: + raise Refused('SOPS decryption failed; no plaintext published') + plain.flush() + os.fsync(plain.fileno()) + # Atomic no-overwrite publication within the private directory. + os.link(temporary, output.name, src_dir_fd=directory, dst_dir_fd=directory, + follow_symlinks=False) + os.unlink(temporary, dir_fd=directory) + os.fsync(directory) + finally: + if keyfd is not None: + os.close(keyfd) + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + os.close(directory) + print('Decrypted to the requested private file. Remove it after the approved operation.') + + +def interrupted(_signum, _frame): + raise Refused('operation interrupted') + + +def main(): + for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + signal.signal(sig, interrupted) + try: + if len(sys.argv) != 2 or sys.argv[1] not in {'setup', 'decrypt'}: + raise Refused('unsupported custody operation') + recipient = public_recipient() + if sys.argv[1] == 'setup': + print('Registered public recipient validated. No private key was read, copied or generated.') + else: + decrypt(recipient) + return 0 + except Refused as exc: + print('ERROR: ' + str(exc), file=sys.stderr) + except (OSError, ValueError, TypeError, ImportError, subprocess.SubprocessError): + # Parser, filesystem and tool errors can include user data. Never echo. + print('ERROR: custody operation failed; input and tool diagnostics suppressed.', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/sso-mfa/k8s/privacyidea/key-custody-remote.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/sso-mfa/k8s/privacyidea/key-custody-remote.py new file mode 100644 index 00000000..99abe4c9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/sso-mfa/k8s/privacyidea/key-custody-remote.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""In-pod key transport. Only invoke through key-custody.py (captured output).""" +import base64 +import fcntl +import json +import os +from pathlib import Path +import secrets +import stat +import sys + +FILES = ('enckey', 'private.pem', 'public.pem') +LIMIT = 65536 + + +class Refused(Exception): + pass + + +def validate(values): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + if set(values) != set(FILES) or len(values['enckey']) != 96: + raise Refused() + if any(not value or len(value) > LIMIT for value in values.values()): + raise Refused() + private = serialization.load_pem_private_key(values['private.pem'], password=None) + public = serialization.load_pem_public_key(values['public.pem']) + if (not isinstance(private, rsa.RSAPrivateKey) or private.key_size < 2048 + or not isinstance(public, rsa.RSAPublicKey) + or private.public_key().public_numbers() != public.public_numbers()): + raise Refused() + + +def read(directory, name): + fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + try: + info = os.fstat(fd) + forbidden = 0o022 if name == 'public.pem' else 0o027 + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_mode & forbidden: + raise Refused() + with os.fdopen(os.dup(fd), 'rb') as stream: + data = stream.read(LIMIT + 1) + after = os.stat(name, dir_fd=directory, follow_symlinks=False) + if len(data) > LIMIT or any(getattr(info, k) != getattr(after, k) + for k in ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns')): + raise Refused() + return data + finally: + os.close(fd) + + +def install(directory, values, *, uid=0, gid=999): + """Verify every existing file before creating anything; never replace.""" + missing = [] + for name, value in values.items(): + try: + if read(directory, name) != value: + raise Refused() + except FileNotFoundError: + missing.append(name) + for name in missing: + temp = '.key-custody-' + secrets.token_hex(12) + fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(values[name]); stream.flush() + os.fchown(stream.fileno(), uid, gid) + os.fchmod(stream.fileno(), 0o644 if name == 'public.pem' else 0o640) + os.fsync(stream.fileno()) + os.link(temp, name, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False) + os.unlink(temp, dir_fd=directory) + os.fsync(directory) + finally: + try: + os.unlink(temp, dir_fd=directory) + except FileNotFoundError: + pass + if any(read(directory, name) != value for name, value in values.items()): + raise Refused() + + +def operate(request, root=Path('/etc/privacyidea'), *, uid=0, gid=999): + if any(p.is_symlink() for p in (root, *root.parents)): + raise Refused() + directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + # The admitted service uses fsGroup 999, which may make its PVC group-writable. + if os.fstat(directory).st_mode & 0o002: + raise Refused() + fcntl.flock(directory, fcntl.LOCK_EX | fcntl.LOCK_NB) + if request == {'action': 'initialize-audit'}: + # This explicit first-bootstrap action never creates/replaces enckey. + enckey = read(directory, 'enckey') + if len(enckey) != 96: + raise Refused() + for name in ('private.pem', 'public.pem'): + try: + os.stat(name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + continue + raise Refused() + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + values = {'enckey': enckey, + 'private.pem': private.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption()), + 'public.pem': private.public_key().public_bytes(serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo)} + validate(values) + install(directory, values, uid=uid, gid=gid) + return {'result': 'initialized'} + if request == {'action': 'export'}: + values = {name: read(directory, name) for name in FILES} + validate(values) + return {name: base64.b64encode(value).decode() for name, value in values.items()} + if set(request) == {'action', 'keys'} and request['action'] == 'restore': + values = {name: base64.b64decode(value, validate=True) for name, value in request['keys'].items()} + validate(values) + install(directory, values, uid=uid, gid=gid) + return {'result': 'restored'} + raise Refused() + finally: + os.close(directory) + + +if __name__ == '__main__': + try: + raw = sys.stdin.buffer.read(4 * LIMIT + 1) + if len(raw) > 4 * LIMIT: + raise Refused() + print(json.dumps(operate(json.loads(raw)))) + except Exception: + # Transport may carry key bytes; diagnostics must never serialize them. + print('Key custody operation refused or failed.', file=sys.stderr) + sys.exit(1) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/run_isolated_keycape_native_fixture.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/run_isolated_keycape_native_fixture.py new file mode 100644 index 00000000..267d8dfb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/run_isolated_keycape_native_fixture.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Prepare/run the exact reviewed T18 candidate on disposable synthetic material. + +Default --plan performs no Docker calls and generates no key/material. Execution +requires the reviewed fixture manifest hash and source closure. This runner calls +the candidate itself; hooks only pause or inject a post-response transport fault. +""" +import argparse +import base64 +from datetime import datetime,timedelta,timezone +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tarfile +import tempfile +import time +import uuid + +ROOT=Path(__file__).resolve().parents[1] +MANIFEST=ROOT/'tests/isolated-keycape-native-fixture-manifest.json' +ISSUER='https://nk49-fixture.invalid' +CASES=('positive','hard-parent-loss','deadline','nonzero-after-accept') + + +def load(name,path): + spec=importlib.util.spec_from_file_location(name,path) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module + +s=load('session',ROOT/'tools/bootstrap-recovery/isolated-keycape-session.py') + + +def require(condition): + if not condition:raise RuntimeError('synthetic_fixture_refused') + + +def reviewed_manifest(expected): + raw=MANIFEST.read_bytes();require(hashlib.sha256(raw).hexdigest()==expected) + manifest=json.loads(raw) + for name,digest in manifest['source_sha256'].items(): + require(hashlib.sha256((ROOT/name).read_bytes()).hexdigest()==digest) + return manifest + + +def private_write(path,value): + path.parent.mkdir(mode=0o700,parents=True,exist_ok=True) + fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as out:out.write(value) + + +def materials(root): + """Reuse the existing full archive fixture factory, no real archive accepted.""" + from cryptography import x509 + from cryptography.hazmat.primitives import hashes,serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + import yaml + existing=load('existing_bundle_fixture',ROOT/'tests/test_identity_bundle.py') + payload=existing.stage.__wrapped__(root) + config={'issuer':ISSUER,'port':8080,'privateKeyPem':'/etc/keycape/key.pem','environment':'dev','tokenLifetime':'5m', + 'lldap':{'url':'ldap://127.0.0.1:1','bindDN':'cn=fixture','bindPW':'synthetic-only','baseDN':'dc=fixture'}, + 'authelia':{'baseURL':'http://127.0.0.1:1','clientId':'synthetic','clientSecret':'synthetic','redirectURI':ISSUER+'/authorize/callback'}, + 'privacyidea':{'baseURL':'http://127.0.0.1:1'}, + 'clients':[{'clientId':'nk49-fixture-consumer','clientType':'confidential','audience':'nk49-fixture', + 'grantTypes':['client_credentials'],'allowedScopes':['fixture:read'],'secretRef':'env:KEYCAPE_FIXTURE_CLIENT_SECRET', + 'serviceSubject':'service:nk49-fixture','tenant':'tenant:fixture','roles':['fixture-reader']}]+ + [{'clientId':name,'displayName':name,'clientType':'public','grantTypes':['authorization_code'], + 'redirectUris':['https://nk49-fixture.invalid/callback'],'allowedScopes':['openid'],'mfaRequired':True} + for name in ('vergabe-demo-company','user-engine-portal')]} + secret_path=payload/'resources/sso/secret/keycape-config.json';secret=json.loads(secret_path.read_text()) + secret['data']['config.yaml']=base64.b64encode(yaml.safe_dump(config).encode()).decode();secret_path.write_text(json.dumps(secret)) + client_path=payload/'resources/sso/secret/keycape-rapp-qonto-client.json';client=json.loads(client_path.read_text()) + client['data']={'client-secret':base64.b64encode(b'synthetic-fixture-client-secret').decode()};client_path.write_text(json.dumps(client)) + factor_path=payload/'resources/sso/secret/keycape-factor-read.json';factor=json.loads(factor_path.read_text()) + factor['data']={'admin-token':base64.b64encode(b'synthetic-unused-factor-reader').decode()};factor_path.write_text(json.dumps(factor)) + dep_path=payload/'resources/sso/deployment/keycape.json';dep=json.loads(dep_path.read_text()) + dep['spec']={'template':{'spec':{'containers':[{'name':'keycape','image':s.adapter.IMAGE,'env':[ + {'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'}, + {'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'}, + {'name':'KEYCAPE_FIXTURE_CLIENT_SECRET','valueFrom':{'secretKeyRef':{'name':'keycape-rapp-qonto-client','key':'client-secret'}}}]}]}}};dep_path.write_text(json.dumps(dep)) + policy={'Revision':1,'Modes':{k:'mandatory' for k in ('vergabe-demo-company','user-engine-portal')},'History':[ + {'reference':'synthetic-retained-receipt','actor':'service:fixture','client':'user-engine-portal','before':'mandatory','after':'mandatory','revision':1,'at':1}]} + # JSON tags in the owner store are lowercase; retain an existing lock inode + # representation as part of the archived tree, never synthesize it after import. + policy={k.lower():v for k,v in policy.items()};(payload/'data/keycape/policy.json').write_text(json.dumps(policy)) + private_write(payload/'data/keycape/policy.json.lock',b'') + key=rsa.generate_private_key(public_exponent=65537,key_size=2048) + subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'nk49-fixture.invalid')]);now=datetime.now(timezone.utc) + cert=(x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now-timedelta(minutes=5)).not_valid_after(now+timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName('nk49-fixture.invalid')]),critical=False) + .add_extension(x509.BasicConstraints(ca=True,path_length=None),critical=True).sign(key,hashes.SHA256())) + tls={'/recovery-tls/server.pem':cert.public_bytes(serialization.Encoding.PEM), + '/recovery-tls/server-key.pem':key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()), + '/recovery-tls/trust.pem':cert.public_bytes(serialization.Encoding.PEM)} + for path,value in tls.items():private_write(root/Path(path).name,value) + age=root/'synthetic-age.key';s.run(['age-keygen','-o',str(age)]) + recipient=s.run(['age-keygen','-y',str(age)]).decode().strip();profile=s.bundle.profile();profile['public_recipient']=recipient + s.bundle.profile=lambda:profile + artifact=root/'net-kingdom-identity-20261016T063159Z-0123456789ab.tar.age';s.bundle.seal(payload,artifact,{'synthetic_native_keycape_fixture':True}) + raw=s.run(['age','-d','-i',str(age),str(artifact)],max_output=s.bundle.LIMIT) + with tarfile.open(fileobj=io.BytesIO(raw)) as archive:manifest=json.load(archive.extractfile('manifest.json')) + transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified','provider':'Storage Share', + 'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':artifact.name,'ciphertext_bytes':artifact.stat().st_size, + 'ciphertext_sha256':hashlib.sha256(artifact.read_bytes()).hexdigest(),'etag':'"synthetic"','fetched_path':str(artifact), + 'independent_owner_retrieval_verified':True} + # Derive public executable identity only from the exact cached admitted image. + probe=s.plan_session();name=probe['public_image_probe'] + try: + s.run(['docker','create','--pull=never','--name',name,'--network','none','--read-only','--label',s.adapter.LABEL+'='+probe['run_id'],s.adapter.IMAGE]) + binary=s.adapter.container_file(name,'/keycape') + finally:s.adapter.cleanup({**probe,'containers':[name]}, {}) + admission={'issuer':ISSUER,'ciphertext_sha256':transfer['ciphertext_sha256'], + 'manifest_sha256':hashlib.sha256(json.dumps(manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest(), + 'verifier_sha256':hashlib.sha256(binary).hexdigest(),'trust_sha256':hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest(), + 'tls_cert_sha256':hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest(), + 'consumer':{'client_id':'nk49-fixture-consumer','secret_env':'KEYCAPE_FIXTURE_CLIENT_SECRET','audience':'nk49-fixture','scope':'fixture:read', + 'subject':'service:nk49-fixture','tenant':'tenant:fixture','roles':'fixture-reader','deny_scope':'fixture:write'}} + private_write(root/'fixture.json',json.dumps({'schema':'nk49.generated-keycape-fixture.v1','transfer':transfer,'admission':admission,'recipient':recipient}).encode()) + + +def worker(root,case): + execution=s.isolate_execution();execution['deadline']=time.monotonic()+(20 if case=='deadline' else 120) + data=json.loads((root/'fixture.json').read_text());require(data['schema']=='nk49.generated-keycape-fixture.v1') + profile=s.bundle.profile();profile['public_recipient']=data['recipient'];s.bundle.profile=lambda:profile + s.local.verify.custody.private_key_fd=lambda:os.open(root/'synthetic-age.key',os.O_RDONLY|os.O_NOFOLLOW) + tls={'/recovery-tls/'+name:(root/name).read_bytes() for name in ('server.pem','server-key.pem','trust.pem')} + receipt={};output=root/(case+'.json');checkpoint=lambda r:output.write_text(json.dumps(r)) + provision=s.provision + def paused(*args): + provision(*args) + if case in ('hard-parent-loss','deadline'): + private_write(root/(case+'.barrier'),b'private-import-and-default-launch-complete') + time.sleep(150) + s.provision=paused + if case=='nonzero-after-accept': + original=s.adapter.subprocess.run;injected=False + def fault(argv,**kwargs): + nonlocal injected + result=original(argv,**kwargs) + if argv[:3]==['docker','--host',s.adapter.ENDPOINT] and 'run' in argv and not injected and result.returncode==0: + injected=True + return subprocess.CompletedProcess(argv,1,b'',b'') + return result + s.adapter.subprocess.run=fault + try:s.execute(data['transfer'],data['admission'],tls,receipt,execution,checkpoint=checkpoint) + except BaseException:receipt['status']='failed' + finally:output.write_text(json.dumps(receipt)) + return int(receipt.get('status')!='isolated_keycape_native_registration_checked') + + +def run_cases(root,summary,cases): + for case in cases: + child=subprocess.Popen([sys.executable,str(Path(__file__).resolve()),'--worker-root',str(root),'--worker-case',case, + '--execute-reviewed-fixture','--expected-manifest-sha256',summary['manifest_sha256']],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + try: + end=time.monotonic()+150 + if case=='hard-parent-loss': + while not (root/(case+'.barrier')).exists() and child.poll() is None and time.monotonic()','exec',optimize=2),{}) + self.assertEqual(json.loads(output.getvalue()),{'original_public_signing_key_in_live_jwks':False}) + +class BoundaryTests(unittest.TestCase): + def test_remote_docker_refused_before_info(self): + session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'} + with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command: + with self.assertRaises(a.Refused):a.inspect_session(session) + self.assertEqual(command.call_count,1) + def test_failed_verifier_cleanup_is_attempted(self): + receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup: + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed') + + def test_cleanup_failure_overrides_success(self): + admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')} + result['public_signing_spki_sha256']='e'*64;receipt={} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified']) + +class ContainmentTests(unittest.TestCase): + def inspect(self,overrides=None,launch=None): + name='nk49-kc-'+('a'*32)+'-issuer' + session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}} + host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']} + host.update(overrides or {}) + def command(argv,**kwargs): + if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]' + if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}' + if argv[-1]=='{{.Id}}':return b'anchor-id' + if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode() + return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode() + with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session) + def test_minimal_network_none_admitted(self):self.inspect() + def test_alternate_same_image_command_refused(self): + with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']}) + def test_cap_add_refused(self): + with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']}) + def test_devices_refused(self): + with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]}) + def test_device_requests_refused(self): + with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]}) + def test_host_namespace_refused(self): + for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'): + with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'}) + def test_security_override_refused(self): + with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']}) + +class CleanupTests(unittest.TestCase): + def test_partial_cleanup_attempts_every_owned_container(self): + session={'run_id':'a'*32,'containers':['first','second','third']};receipt={} + removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return ('a'*32).encode() + if argv[1]=='rm': + removed.append(argv[-1]) + if argv[-1]=='third':raise a.Refused() + return b'' + if argv[1:3]==['container','ls']: + name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') + return (name+'\n').encode() if name=='third' or name not in removed else b'' + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['third','second','first']) + self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified']) + def test_ownership_mismatch_does_not_remove_and_continues(self): + session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode() + if argv[1]=='rm':removed.append(argv[-1]) + if argv[1:3]==['container','ls']: + name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') + return (name+'\n').encode() if name not in removed else b'' + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['first']) + self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed') + def test_inspect_failure_retains_uncertain_obligation(self): + receipt={} + with patch.object(a,'command',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt) + self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}]) + +class JWKSTests(unittest.TestCase): + def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'): + request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'} + def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=') + responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}] + if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2')) + class Response: + def __init__(self,value):self.value=value + def __enter__(self):return self + def __exit__(self,*args):pass + def read(self,*args):return json.dumps(self.value).encode() + class Opener: + def open(self,*args,**kwargs):return Response(responses.pop(0)) + with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()): + exec(compile(a.JWKS_PROBE,'','exec',optimize=2),{}) + def test_exact_original_public_key(self):self.execute() + def test_extra_signing_key_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(extra=True) + def test_wrong_kid_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(kid='replacement') + def test_wrong_live_signing_key_refused(self): + with self.assertRaises(ValueError):self.execute(n='124') + def test_cross_origin_jwks_refused(self): + with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks') + def test_production_dns_refused_before_fetch(self): + with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1') + +if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/test_isolated_keycape_session.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/test_isolated_keycape_session.py new file mode 100644 index 00000000..6d8c840c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tests/test_isolated_keycape_session.py @@ -0,0 +1,258 @@ +import base64 +import importlib.util +import json +import io +import os +import tempfile +import signal +import subprocess +import sys +import time +from pathlib import Path +import unittest +from unittest.mock import patch + +SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py' +spec=importlib.util.spec_from_file_location('session',SOURCE) +s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) + +class SessionTests(unittest.TestCase): + def test_tmpfs_plan_has_four_exact_original_mounts(self): + session=s.plan_session() + self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS)) + self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values())) + self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE) + def test_non_tmpfs_driver_options_refused(self): + session=s.plan_session();name,spec=next(iter(session['volumes'].items())) + wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}] + with patch.object(s,'run',return_value=json.dumps(wrong).encode()): + with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id']) + def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self): + session=s.plan_session();events=[] + payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}} + def run(argv,**kwargs): + events.append(('run',argv,kwargs)) + if argv[-1]=='{{.Id}}':return b'anchor' + return b'' + with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))): + s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example') + joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run') + self.assertNotIn('fixture-sensitive',joined) + private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1]) + inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect') + self.assertLess(inspect_index,private_index) + issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE) + self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive') + self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1]) + self.assertNotIn('--entrypoint',issuer_call[1]) + def test_non_original_https_port_refused_before_any_mutation(self): + with patch.object(s,'run') as run: + with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example') + run.assert_not_called() + def test_container_failure_still_attempts_each_volume_cleanup(self): + session=s.plan_session();receipt={};removed=[] + def run(argv,**kwargs): + if argv[1:3]==['volume','rm']:removed.append(argv[-1]) + if argv[1:3]==['volume','ls']: + name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$') + return (name+'\n').encode() if name not in removed else b'' + return b'' + with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run): + with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt) + self.assertEqual(set(removed),set(session['volumes'])) + self.assertFalse(receipt['container_cleanup_verified']) + self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4) + def test_reaper_inherits_no_custody_or_service_environment(self): + session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']} + with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'verify_execution'),patch.object(s.os,'pidfd_open',return_value=99),patch.object(s.os,'close'),patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])): + spawn.return_value.stdout.readline.return_value=b'registered\n' + s.start_reaper(session,staging,{'pid':123}) + kwargs=spawn.call_args.kwargs + self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'}) + self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']);self.assertEqual(kwargs['pass_fds'],(99,)) + +class TargetBindingTests(unittest.TestCase): + def test_every_direct_docker_command_is_explicitly_bound(self): + with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run: + self.assertEqual(s.run(['docker','info']),b'ok') + argv=run.call_args.args[0] + self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT]) + self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT) + def test_nondefault_unix_socket_is_refused_before_any_docker_call(self): + with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) + run.assert_not_called() + def test_conflicting_context_is_refused(self): + with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) + run.assert_not_called() + def test_conflicting_per_call_host_is_refused(self): + with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'}) + run.assert_not_called() + def test_reused_preflight_uses_same_bound_command(self): + self.assertIs(s.local.command,s.adapter.command) + def test_probe_is_registered_before_session_creation(self): + session=s.plan_session() + self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe') + self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT) + +class PlaintextLifecycleTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name) + self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop) + self.run_id='a'*32 + self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700) + self.staging=s.register_workdir(self.work,self.run_id) + def test_exact_plaintext_directory_removed_and_recorded(self): + (self.work/'archive.tar').write_bytes(b'synthetic-private-archive') + restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic') + receipt={};s.cleanup_workdir(self.staging,receipt) + self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) + self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified') + def test_docker_failure_does_not_skip_plaintext_cleanup(self): + (self.work/'archive.tar').write_bytes(b'synthetic') + receipt={} + with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused): + with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt) + self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) + def test_replacement_inode_is_not_removed(self): + # Preserve original inode while replacing its registered pathname. + old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700) + (self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={} + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt) + self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists()) + self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed') + def test_symlink_root_is_not_followed(self): + self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep') + self.work.symlink_to(outside,target_is_directory=True) + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) + self.assertTrue((outside/'kept').exists()) + def test_descendant_symlink_is_not_followed(self): + outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside) + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) + self.assertEqual(outside.read_bytes(),b'keep') + def test_wrong_owner_identity_is_not_removed(self): + changed={**self.staging,'owner_uid':os.getuid()+1} + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{}) + self.assertTrue(self.work.exists()) + def test_unregistered_path_refused(self): + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {}) + +@unittest.skipUnless(hasattr(os,'pidfd_open') and hasattr(signal,'pidfd_send_signal'),'Linux pidfd required') +class ExecutionDeadlineTests(unittest.TestCase): + def start_synthetic_owner(self,root): + late=str(root/'late-creation') + code="""import importlib.util,json,subprocess,sys,time +spec=importlib.util.spec_from_file_location('session',sys.argv[1]);s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) +execution=s.isolate_execution() +child=subprocess.Popen([sys.executable,'-c',"import pathlib,sys,time;time.sleep(2);pathlib.Path(sys.argv[1]).write_text('late')",sys.argv[2]]) +print(json.dumps({'execution':execution,'child_pid':child.pid}),flush=True) +time.sleep(20) +""" + parent=subprocess.Popen([sys.executable,'-c',code,str(SOURCE),late],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL) + message=json.loads(parent.stdout.readline());parent.stdout.close() + def finish(): + if parent.poll() is None:parent.kill() + parent.wait(timeout=5) + try: + before=s.process_facts(message['child_pid']) + if before is None or before['sid']!=message['execution']['sid'] or before['pgid']!=message['execution']['pgid']:return + fd=os.pidfd_open(message['child_pid']) + try: + after=s.process_facts(message['child_pid']) + if after is not None and all(after[k]==before[k] for k in ('pid','sid','pgid','start_ticks','uid')): + signal.pidfd_send_signal(fd,signal.SIGKILL) + except ProcessLookupError:pass + finally:os.close(fd) + except ProcessLookupError:pass + self.addCleanup(finish) + return parent,message['execution'],Path(late) + def test_deadline_quiesces_only_fresh_owner_group_before_cleanup(self): + with tempfile.TemporaryDirectory() as directory: + parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid) + try: + receipt={};s.quiesce_execution(execution,fd,receipt) + self.assertTrue(receipt['execution_quiescence_verified']) + self.assertFalse(s.group_members(execution));self.assertFalse(late.exists()) + finally:os.close(fd) + def test_hard_parent_loss_stops_surviving_marker_creator(self): + with tempfile.TemporaryDirectory() as directory: + parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid) + try: + signal.pidfd_send_signal(fd,signal.SIGKILL) + poll=__import__('select').poll();poll.register(fd,__import__('select').POLLIN);self.assertTrue(poll.poll(2000)) + receipt={};s.quiesce_execution(execution,fd,receipt) + self.assertTrue(receipt['execution_quiescence_verified']);self.assertFalse(late.exists()) + self.assertFalse(s.group_members(execution)) + finally:os.close(fd) + def test_expired_execution_cannot_start_any_docker_command(self): + binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()-1,'staging':'/tmp/not-created'} + with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','create','fixed-image']) + run.assert_not_called() + def test_reused_or_foreign_session_refuses_signals(self): + binding={'pid':123,'sid':123,'pgid':123,'start_ticks':10,'uid':os.getuid()} + wrong={**binding,'sid':456,'state':'S'} + with patch.object(s,'process_facts',return_value=wrong),patch.object(s.signal,'pidfd_send_signal') as send: + with self.assertRaises(s.adapter.Refused):s.quiesce_execution(binding,99,{}) + send.assert_not_called() + def test_inflight_uncertainty_survives_plaintext_directory_removal(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'owned-stage';path.mkdir();marker=Path(str(path)+'-docker-inflight');marker.touch() + staging={'path':str(path)};self.assertTrue(s.daemon_uncertain(staging));path.rmdir() + self.assertTrue(s.daemon_uncertain(staging)) + +class ReaperOrderingTests(unittest.TestCase): + def exercise(self,quiescence_fails=False): + events=[];records=[] + def quiet(*args): + events.append('quiesce') + if quiescence_fails:raise s.adapter.Refused() + args[-1]['execution_quiescence_verified']=True + def recover(*args):events.append('cleanup') + with patch.object(s,'verify_execution'),patch.object(s,'pidfd_dead',return_value=False),patch.object(s.select,'poll') as poll,patch.object(s,'quiesce_execution',side_effect=quiet),patch.object(s,'daemon_uncertain',return_value=True),patch.object(s,'recover_obligations',side_effect=recover),patch.object(s.os,'close'),patch.object(s.os,'open',return_value=99),patch.object(s.os,'fdopen'),patch.object(s.json,'dump',side_effect=lambda r,*args:records.append(dict(r))),patch('sys.stdout',io.StringIO()): + poll.return_value.poll.return_value=[] + s.reaper_main({}, {'path':'/dev/shm/exact-staging'}, {'deadline':time.monotonic()-1},77) + return events,records[0] + def test_daemon_uncertainty_survives_verified_resource_readback(self): + events,receipt=self.exercise();self.assertEqual(events,['quiesce','cleanup']) + self.assertTrue(receipt['execution_quiescence_verified']);self.assertTrue(receipt['in_flight_daemon_request_uncertain']) + self.assertEqual(receipt['status'],'bounded_cleanup_uncertain') + def test_unverified_quiescence_prevents_any_cleanup(self): + events,receipt=self.exercise(True);self.assertEqual(events,['quiesce']) + self.assertFalse(receipt['execution_quiescence_verified']);self.assertEqual(receipt['status'],'bounded_cleanup_uncertain') + def test_checkpoint_rewrites_one_valid_json_document(self): + with tempfile.TemporaryFile() as stream: + s.persist_receipt(stream.fileno(),{'status':'registered','long':'x'*100}) + s.persist_receipt(stream.fileno(),{'status':'failed'}) + stream.seek(0);self.assertEqual(json.load(stream),{'status':'failed'}) + def test_nonzero_docker_result_retains_uncertainty_before_inflight_clear(self): + with tempfile.TemporaryDirectory() as directory: + staging=str(Path(directory)/'stage');Path(staging).mkdir() + binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging} + def returned(*args,**kwargs): + self.assertTrue(Path(staging+'-docker-inflight').exists()) + return subprocess.CompletedProcess(args[0],1,b'') + with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=returned): + with self.assertRaises(s.adapter.Refused):s.run(['docker','create','synthetic-already-accepted']) + self.assertFalse(Path(staging+'-docker-inflight').exists());self.assertTrue(s.daemon_uncertain({'path':staging})) + def test_only_policy_mount_is_writable_in_issuer(self): + plan=s.plan_session();args=s.mounts(plan,readonly=True) + mounts=args[1::2] + for value in mounts: + self.assertEqual(not value.endswith(',readonly'),'target=/var/lib/keycape-policy' in value) + def test_docker_timeout_retains_independent_uncertainty_marker(self): + with tempfile.TemporaryDirectory() as directory: + staging=str(Path(directory)/'stage');Path(staging).mkdir() + binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging} + def timeout(*args,**kwargs): + self.assertTrue(Path(staging+'-docker-inflight').exists()) + raise subprocess.TimeoutExpired('synthetic Docker',1) + with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=timeout): + with self.assertRaises(subprocess.TimeoutExpired):s.run(['docker','create','fixed-image']) + self.assertFalse(Path(staging+'-docker-inflight').exists()) + self.assertTrue(Path(staging+'-docker-uncertain').exists());self.assertTrue(s.daemon_uncertain({'path':staging})) + +if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/capture-identity.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/capture-identity.py new file mode 100644 index 00000000..50f61535 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/capture-identity.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Owner-run identity capture: metadata plan first, exact bounded execution later. + +Use the admitted Railiance caller. Flags bind reviewed source/targets; they do +not grant authority. Private child output and provider diagnostics never print. +""" +import argparse +from datetime import datetime,timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import uuid + +HERE=Path(__file__).resolve().parent + + +def load(name): + spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py')) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module + + +bundle=load('identity-bundle');prepare=load('prepare-identity-coverage') +IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473' +SOURCES=['capture-identity.py','identity-files.py','identity-bundle.py','prepare-identity-coverage.py','identity-backup-profile.json'] +KUBE=['kubectl'] + + +class Refused(Exception):pass + + +def command(args,payload=None,timeout=60): + result=subprocess.run(KUBE+['--request-timeout=30s']+args,input=payload, + stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=timeout) + if result.returncode:raise Refused() + return result.stdout + + +def get(ns,kind,name): + scope=['-n',ns] if ns else [] + return json.loads(command(scope+['get',kind,name,'-o','json'])) + + +def selects(selector,labels): + if any(labels.get(k)!=v for k,v in selector.get('matchLabels',{}).items()):return False + for expression in selector.get('matchExpressions',[]): + key=expression['key'];op=expression['operator'];values=expression.get('values',[]) + if op=='In' and labels.get(key) not in values:return False + if op=='NotIn' and key in labels and labels[key] in values:return False + if op=='Exists' and key not in labels:return False + if op=='DoesNotExist' and key in labels:return False + if op not in {'In','NotIn','Exists','DoesNotExist'}:raise Refused() + return True + + +def network_review(run_id,actual_labels=None): + rows=[] + for ns in ('sso','mfa'): + policies=json.loads(command(['-n',ns,'get','networkpolicies','-o','json']))['items'] + for obj in policies: + m=obj['metadata'];s=obj['spec'] + labels=(actual_labels or {}).get(ns,{'net-kingdom/capture':run_id}) + if selects(s['podSelector'],labels) and (s.get('ingress') or s.get('egress')): + rows.append({'namespace':ns,'name':m['name'],'uid':m['uid'], + 'spec_sha256':hashlib.sha256(json.dumps(s,sort_keys=True).encode()).hexdigest()}) + return rows + + +def validate_helper(actual,wanted): + a=actual['spec'];w=wanted['spec'] + if (any(a.get(k) for k in ('hostNetwork','hostPID','hostIPC','initContainers','ephemeralContainers')) + or a.get('automountServiceAccountToken') is not False or a.get('activeDeadlineSeconds')!=900 + or len(a['containers'])!=1 or a['volumes']!=w['volumes']):raise Refused() + c=a['containers'][0];expected=w['containers'][0] + if any(c.get(k)!=expected[k] for k in ('name','image','command','volumeMounts')):raise Refused() + if c.get('env') or c.get('envFrom') or c.get('securityContext',{}).get('privileged'):raise Refused() + if any(c.get('securityContext',{}).get(k)!=v for k,v in expected['securityContext'].items()):raise Refused() + if any(a.get('securityContext',{}).get(k)!=v for k,v in w['securityContext'].items()):raise Refused() + + +def manifests(run_id): + result=[] + for ns,claims in [('sso',[('lldap','lldap-data'),('authelia','authelia-data'),('keycape','keycape-authentication-policy')]), + ('mfa',[('privacyidea','privacyidea-data')])]: + label={'net-kingdom/capture':run_id};name='nk49-capture-'+run_id + result.append({'apiVersion':'networking.k8s.io/v1','kind':'NetworkPolicy', + 'metadata':{'name':name,'namespace':ns}, + 'spec':{'podSelector':{'matchLabels':label},'policyTypes':['Ingress','Egress'],'ingress':[],'egress':[]}}) + result.append({'apiVersion':'v1','kind':'Pod','metadata':{'name':name,'namespace':ns,'labels':label}, + 'spec':{'restartPolicy':'Never','activeDeadlineSeconds':900,'automountServiceAccountToken':False, + 'securityContext':{'runAsUser':0,'runAsGroup':0}, + 'containers':[{'name':'capture','image':IMAGE,'imagePullPolicy':'IfNotPresent', + 'command':['python3','-c','import time; time.sleep(900)'], + 'securityContext':{'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True, + 'capabilities':{'drop':['ALL'],'add':['DAC_OVERRIDE']}}, + 'resources':{'requests':{'cpu':'50m','memory':'128Mi'},'limits':{'cpu':'1','memory':'2Gi'}}, + 'volumeMounts':[{'name':key,'mountPath':'/source/'+key,'readOnly':True} for key,_ in claims]+ + [{'name':'scratch','mountPath':'/scratch'}]}], + 'volumes':[{'name':key,'persistentVolumeClaim':{'claimName':claim,'readOnly':True}} for key,claim in claims]+ + [{'name':'scratch','emptyDir':{'medium':'Memory','sizeLimit':'512Mi'}}]}}) + return result + + +def plan(): + p=bundle.profile();binding=prepare.prepare(p,get=get) + cluster=get('databases','cluster','net-kingdom-pg') + primary=cluster['status']['currentPrimary'];pod=get('databases','pod',primary) + if (not any(o.get('uid')==cluster['metadata']['uid'] for o in pod['metadata'].get('ownerReferences',[])) + or not any(c['type']=='Ready' and c['status']=='True' for c in pod['status'].get('conditions',[]))):raise Refused() + run_id=uuid.uuid4().hex[:12] + return {'schema':'netkingdom.identity-capture-plan.v1','status':'requires_owner_execution_admission', + 'created_at':datetime.now(timezone.utc).isoformat(),'run_id':run_id,'binding':binding,'kubeconfig':KUBE[2], + 'sources':[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES], + 'database':{'namespace':'databases','cluster':'net-kingdom-pg','cluster_uid':cluster['metadata']['uid'], + 'pod':primary,'pod_uid':pod['metadata']['uid'],'database':'privacyidea_db'}, + 'create_resources':manifests(run_id),'service_changes':False,'credential_grants':False, + 'matching_network_allow_policies':network_review(run_id), + 'network_policy_enforcement_requires_owner_confirmation':True, + 'offsite_delivery_included':False,'global_atomic_snapshot_claimed':False} + + +def checked_plan(path,expected): + raw=path.read_bytes() + if hashlib.sha256(raw).hexdigest()!=expected:raise Refused() + p=json.loads(raw) + if p.get('schema')!='netkingdom.identity-capture-plan.v1':raise Refused() + if p.get('kubeconfig')!=KUBE[2]:raise Refused() + if network_review(p['run_id']):raise Refused() + if p['sources']!=[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES]:raise Refused() + if p['create_resources']!=manifests(p['run_id']):raise Refused() + now=datetime.now(timezone.utc) + if not 0<=(now-datetime.fromisoformat(p['created_at'])).total_seconds()<3600:raise Refused() + # Recheck the exact immutable source/target bindings before private reads. + current=prepare.prepare(bundle.profile(),get=get) + if current['bindings']!=p['binding']['bindings'] or current['profile_sha256']!=p['binding']['profile_sha256']:raise Refused() + return p + + +def delete_created(obj): + m=obj['metadata'];ns=m['namespace'];name=m['name'];uid=m['uid'] + if obj['kind']=='Pod':path=f'/api/v1/namespaces/{ns}/pods/{name}' + elif obj['kind']=='NetworkPolicy':path=f'/apis/networking.k8s.io/v1/namespaces/{ns}/networkpolicies/{name}' + else:raise Refused() + payload=json.dumps({'apiVersion':'v1','kind':'DeleteOptions','preconditions':{'uid':uid},'gracePeriodSeconds':1}).encode() + command(['delete','--raw',path,'-f','-'],payload) + if obj['kind']=='Pod':command(['-n',ns,'wait','--for=delete','pod/'+name,'--timeout=90s'],timeout=100) + + +def bounded_file(args,path,limit): + # Stream binary dump to private staging, with a byte bound and suppressed + # diagnostics. API command argv contains only public resource/database names. + proc=subprocess.Popen(KUBE+['--request-timeout=600s']+args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL) + try: + count=0 + with path.open('xb') as out: + while block:=proc.stdout.read(1024*1024): + count+=len(block) + if count>limit:raise Refused() + out.write(block) + if proc.wait(timeout=600):raise Refused() + finally: + if proc.poll() is None:proc.kill();proc.wait() + + +def capture(p,output,receipt,decision,checkpoint=lambda:None): + created=[];receipt.update(status='capture_started',decision_ref=decision,plan_run_id=p['run_id'], + created_resources=[],unverified_create_outcomes=[],helper_cleanup_verified=False,temporary_plaintext_cleanup_verified=False) + checkpoint() + with tempfile.TemporaryDirectory(prefix='nk49-identity-',dir='/tmp') as temporary: + receipt['temporary_plaintext_directory']=temporary + checkpoint() + root=Path(temporary)/'payload';root.mkdir(mode=0o700) + held={} + try: + for c in bundle.profile()['components']: + for kind,names in [('deployment',[c['deployment']]),('secret',c['secret_refs']),('configmap',c['configmap_refs'])]: + for name in names: + obj=get(c['namespace'],kind,name) + # Keep reconstructable deployed resources inside encryption, + # without unrelated stale last-applied annotations. + m=obj['metadata'];obj['metadata']={k:m[k] for k in ['name','namespace','uid','resourceVersion']} + obj.pop('status',None) + raw=json.dumps(obj,sort_keys=True).encode();slot=f"resources/{c['namespace']}/{kind}/{name}.json" + held[(c['namespace'],kind,name)]=(m['uid'],m['resourceVersion']) + bundle.save(root,slot,raw) + for proposed in p['create_resources']: + # create, never adopt/apply an existing resource. Unknown create + # outcomes stop; only returned exact UIDs are eligible for cleanup. + pending={'kind':proposed['kind'],**proposed['metadata']} + receipt['unverified_create_outcomes'].append(pending) + checkpoint() + obj=json.loads(command(['create','-f','-','-o','json'],json.dumps(proposed).encode())) + if (obj['kind']!=proposed['kind'] or obj['metadata']['name']!=proposed['metadata']['name'] + or obj['metadata']['namespace']!=proposed['metadata']['namespace'] or not obj['metadata'].get('uid')):raise Refused() + created.append(obj) + receipt['unverified_create_outcomes'].remove(pending) + receipt['created_resources'].append({'namespace':obj['metadata']['namespace'], + 'kind':obj['kind'],'name':obj['metadata']['name'],'uid':obj['metadata']['uid']}) + checkpoint() + for ns in ('sso','mfa'): + name='nk49-capture-'+p['run_id'] + command(['-n',ns,'wait','--for=condition=Ready','pod/'+name,'--timeout=180s'],timeout=190) + live=get(ns,'pod',name) + expected=next(o for o in created if o['kind']=='Pod' and o['metadata']['namespace']==ns) + if live['metadata']['uid']!=expected['metadata']['uid']:raise Refused() + wanted=next(o for o in p['create_resources'] if o['kind']=='Pod' and o['metadata']['namespace']==ns) + validate_helper(live,wanted) + if network_review(p['run_id'],{ns:live['metadata']['labels']}):raise Refused() + raw=command(['-n',ns,'exec','-i',name,'-c','capture','--','python3','-c', + (HERE/'identity-files.py').read_text(),ns],timeout=600) + if len(raw)>bundle.LIMIT+2*1024*1024:raise Refused() + bundle.import_files(raw,root,ns) + if get(ns,'pod',name)['metadata']['uid']!=expected['metadata']['uid']:raise Refused() + db=p['database'];current=get('databases','cluster','net-kingdom-pg') + if current['metadata']['uid']!=db['cluster_uid'] or current['status']['currentPrimary']!=db['pod']:raise Refused() + if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused() + (root/'databases').mkdir(mode=0o700) + bounded_file(['-n','databases','exec',db['pod'],'-c','postgres','--','pg_dump','-U','postgres', + '-d','privacyidea_db','-Fc','--no-owner','--no-acl'],root/'databases/privacyidea.dump',bundle.FILE_LIMIT) + if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused() + for (ns,kind,name),binding in held.items(): + m=get(ns,kind,name)['metadata'] + if (m['uid'],m['resourceVersion'])!=binding:raise Refused() + # Re-read original keys after the database dump. No rotation is + # inferred from file presence; a changed key set refuses publication. + raw=command(['-n','mfa','exec','-i','nk49-capture-'+p['run_id'],'-c','capture','--','python3','-c', + (HERE/'identity-files.py').read_text(),'mfa'],timeout=60) + with tempfile.TemporaryDirectory(prefix='nk49-key-recheck-',dir='/tmp') as keytemp: + again=Path(keytemp);bundle.import_files(raw,again,'mfa') + for name in ('enckey','private.pem','public.pem'): + if (root/'data/privacyidea'/name).read_bytes()!=(again/'data/privacyidea'/name).read_bytes():raise Refused() + receipt.update(bundle.seal(root,output,{'started_at':receipt['started_at'], + 'finished_at':datetime.now(timezone.utc).isoformat(),'database':db,'decision_ref':decision, + 'cluster_uid':prepare.CLUSTER_UID,'global_atomic_snapshot_claimed':False, + 'omissions':['historical local backups','SQLite WAL/SHM/journal represented by native snapshots', + 'Authelia notification.txt','privacyIDEA service logs; audit data retained in database', + 'OpenBao root/unseal/age private key custody remains independently held']})) + finally: + failures=[] + for obj in reversed(created): + try:delete_created(obj) + except Exception:failures.append(obj['metadata']['uid']) + receipt['helper_cleanup_verified']=not failures and not receipt['unverified_create_outcomes'] + checkpoint() + if not receipt['helper_cleanup_verified']:raise Refused() + receipt['temporary_plaintext_cleanup_verified']=not Path(temporary).exists() + checkpoint() + if not receipt['temporary_plaintext_cleanup_verified']:raise Refused() + + +def main(): + global KUBE + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('action',choices=['plan','capture']) + parser.add_argument('--kubeconfig',required=True) + parser.add_argument('--receipt',required=True);parser.add_argument('--plan');parser.add_argument('--plan-sha256') + parser.add_argument('--output');parser.add_argument('--decision-id');a=parser.parse_args();os.umask(0o077) + KUBE=['kubectl','--kubeconfig',str(Path(a.kubeconfig).absolute())] + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + os.close(fd) + r={'status':'failed','started_at':datetime.now(timezone.utc).isoformat(),'secret_values_exported':False} + def checkpoint(): + fd,temp=tempfile.mkstemp(prefix='.capture-receipt-',dir=Path(a.receipt).absolute().parent) + try: + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n');out.flush();os.fsync(out.fileno()) + os.replace(temp,a.receipt) + finally:Path(temp).unlink(missing_ok=True) + code=1 + try: + if a.action=='plan': + if any([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused() + r=plan();code=0 + else: + if not all([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused() + uuid.UUID(a.decision_id) + p=checked_plan(Path(a.plan),a.plan_sha256) + capture(p,Path(a.output),r,a.decision_id,checkpoint);code=0 + except Exception: + r.update(status='failed',error='capture_refused_or_failed_private_diagnostics_suppressed') + if r.get('temporary_plaintext_directory') and not Path(r['temporary_plaintext_directory']).exists():r['temporary_plaintext_cleanup_verified']=True + finally: + r['finished_at']=datetime.now(timezone.utc).isoformat() + checkpoint() + print('OK: identity '+a.action+' complete; see safe receipt.' if code==0 else 'ERROR: identity operation failed; inspect safe receipt before retry.') + return code + + +def interrupted(*args):raise Refused() + + +if __name__=='__main__': + for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/check-privacyidea-database.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/check-privacyidea-database.py new file mode 100644 index 00000000..d00361be --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/check-privacyidea-database.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Restore the fetched PI database in an isolated, disposable local PostgreSQL. + +Run only through the attended custody helper. No cluster, production database, +network, ports, host data mounts, IAM login or token decryption is involved. +""" +import argparse +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location('custody',ROOT/'sso-mfa/bootstrap/sops-private.py') +custody=importlib.util.module_from_spec(spec);spec.loader.exec_module(custody) +RECIPIENT='age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz' +IMAGE='postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70' +OBJECT='r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age' +SIZE=89129309 + + +class Refused(Exception):pass + + +def run(args,**kwargs): + result=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=600,**kwargs) + if result.returncode:raise Refused() + return result.stdout + + +def transfer_input(receipt): + r=json.loads(receipt.read_text()) + if (r.get('schema')!='netkingdom.privacyidea-offhost-transfer.v1' or + r.get('status')!='ciphertext_fetched' or r.get('object')!=OBJECT or + r.get('ciphertext_bytes')!=SIZE or r.get('object_unchanged_during_transfer') is not True):raise Refused() + path=Path(r['ciphertext_path']) + if not path.is_absolute() or path.parent.parent!=Path('/tmp') or not path.parent.name.startswith('nk49-offhost-'):raise Refused() + if any(p.is_symlink() for p in (path,*path.parents)):raise Refused() + st=path.stat() + if st.st_uid!=os.getuid() or st.st_mode&0o077 or st.st_nlink!=1 or not path.is_file() or st.st_size!=SIZE:raise Refused() + blob=path.read_bytes() + if len(blob)!=SIZE or hashlib.sha256(blob).hexdigest()!=r['ciphertext_sha256']:raise Refused() + return blob,r['ciphertext_sha256'] + + +def restore(dump,container): + # Require the exact image already present; do not pull through a mutable tag. + image_id=run(['docker','image','inspect','--format','{{.Id}}',IMAGE]).decode().strip() + run(['docker','run','--detach','--pull=never','--name',container,'--network','none', + '--security-opt','no-new-privileges','--memory','2g','--cpus','2', + '--tmpfs','/var/lib/postgresql/data:rw,nosuid,nodev,size=1073741824', + '--env','POSTGRES_HOST_AUTH_METHOD=trust','--env','POSTGRES_DB=recovery', + IMAGE,'postgres','-c','listen_addresses=']) + for _ in range(60): + # The image starts a temporary server during initdb. Wait until the + # entrypoint has exec'd the final server before attempting the restore. + pid1=subprocess.run(['docker','exec',container,'cat','/proc/1/comm'], + stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10) + if pid1.returncode or pid1.stdout.strip()!=b'postgres': + time.sleep(1);continue + ready=subprocess.run(['docker','exec',container,'pg_isready','-U','postgres','-d','recovery'], + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10) + if ready.returncode==0:break + time.sleep(1) + else:raise Refused() + version=run(['docker','exec',container,'pg_restore','--version']).decode().strip() + with dump.open('rb') as source: + run(['docker','exec','-i',container,'pg_restore','-U','postgres','-d','recovery', + '--no-owner','--no-privileges','--exit-on-error'],stdin=source) + # Fixed aggregate queries only. No identity, resolver config or token values. + counts={} + for table in ('token','resolver','realm'): + raw=run(['docker','exec',container,'psql','-X','-U','postgres','-d','recovery','-At', + '-v','ON_ERROR_STOP=1','-c',f'SELECT count(*) FROM public.{table};']).strip() + if not raw.isdigit():raise Refused() + counts[table]=int(raw) + return {'image':IMAGE,'image_id':image_id,'pg_restore_version':version,'aggregate_row_counts':counts} + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077) + # Reserve the receipt before any recovery action; never overwrite evidence. + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.privacyidea-database-drill.v1','status':'failed', + 'started_at':datetime.now(timezone.utc).isoformat(),'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'production_contacted':False,'secret_values_exported':False,'native_identity_recovery_proven':False, + 'token_decryption_proven':False,'audit_signature_continuity_proven':False, + 'full_identity_file_coverage_proven':False,'container_cleanup_verified':False, + 'temporary_plaintext_cleanup_verified':False} + container='nk49-pi-recovery-'+uuid.uuid4().hex + keyfd=None;work=None + try: + blob,digest=transfer_input(Path(a.transfer_receipt));r['ciphertext_sha256']=digest + keyfd=custody.private_key_fd() + derived=run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip() + if derived!=RECIPIENT.encode():raise Refused() + r['registered_recipient_match']=True + with tempfile.TemporaryDirectory(prefix='nk49-pi-drill-',dir='/tmp') as temporary: + work=Path(temporary) + env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary} + wrong=work/'wrong.key' + run(['age-keygen','-o',str(wrong)],env=env) + negative=subprocess.run(['age','-d','-i',str(wrong)],input=blob,stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL,timeout=60,env=env) + if negative.returncode==0:raise Refused() + r['wrong_key_refused']=True + dump=work/'privacyidea.dump' + with dump.open('xb') as output: + result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}'],input=blob, + stdout=output,stderr=subprocess.DEVNULL,timeout=120,pass_fds=(keyfd,),env=env) + if result.returncode or not 5200 or not etag.startswith('"') or not etag.endswith('"') or + any(ord(c)<32 for c in etag)): + raise owner.Refused() + return etag + + +def transfer(opener,auth,destination): + url=owner.HOST+owner.BASE+OBJECT + headers={'Authorization':'Basic '+auth,'Accept-Encoding':'identity'} + with opener.open(urllib.request.Request(url,method='HEAD',headers=headers),timeout=30) as response: + etag=metadata(response,url) + headers['If-Match']=etag + digest=hashlib.sha256();count=0 + with opener.open(urllib.request.Request(url,headers=headers),timeout=60) as response: + if metadata(response,url)!=etag:raise owner.Refused() + fd=os.open(destination,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as out: + while chunk:=response.read(1024*1024): + count+=len(chunk) + if count>SIZE:raise owner.Refused() + digest.update(chunk);out.write(chunk) + out.flush();os.fsync(out.fileno()) + if count!=SIZE:raise owner.Refused() + with opener.open(urllib.request.Request(url,method='HEAD',headers=headers),timeout=30) as response: + if metadata(response,url)!=etag:raise owner.Refused() + # ETag used for request binding only; it is not asserted to be a content hash. + return {'ciphertext_bytes':count,'ciphertext_sha256':digest.hexdigest(), + 'object_unchanged_during_transfer':True} + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077) + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.privacyidea-offhost-transfer.v1','status':'failed', + 'started_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share', + 'object':OBJECT,'age_keys_read':False,'plaintext_created':False, + 'production_writes':False,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'owner_accessor_sha256':hashlib.sha256((HERE/'inventory-storage-share.py').read_bytes()).hexdigest()} + destination=None + try: + identity,credentials=owner.caller_credentials() + r['caller_auth_path']=identity['path'];r['caller_entity_id']=identity.get('entity_id') + auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode() + destination=Path(tempfile.mkdtemp(prefix='nk49-offhost-',dir='/tmp'))/'privacyidea.dump.age' + opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),owner.NoRedirect()) + r.update(transfer(opener,auth,destination)) + r.update(status='ciphertext_fetched',ciphertext_path=str(destination)) + except Exception: + r['error']='bounded_transfer_failed_diagnostics_suppressed' + if destination is not None: + destination.unlink(missing_ok=True);destination.parent.rmdir() + finally: + r['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-backup-profile.json b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-backup-profile.json new file mode 100644 index 00000000..8f380f80 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-backup-profile.json @@ -0,0 +1,158 @@ +{ + "schema": "netkingdom.identity-backup-profile.v1", + "status": "implemented_pending_owner_admission", + "owner": "net-kingdom with railiance-platform/rapp-postgres runtime and storage admission", + "public_recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz", + "archive_profile": "netkingdom-identity-v1; new profile, not the bootstrap secrets-only tar profile", + "components": [ + { + "namespace": "sso", + "deployment": "lldap", + "deployment_uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8", + "pvc_refs": [ + { + "name": "lldap-data", + "uid": "0a06b65a-e7b3-42d3-916e-e74dbe5fc7da" + } + ], + "secret_refs": [ + "lldap-secrets" + ], + "configmap_refs": [], + "required_assets": [ + "SQLite online-backup snapshot of /data/users.db with integrity check", + "remaining non-backup durable /data files, including any private identity material", + "current deployment configuration and exact lldap-secrets fields" + ], + "native_acceptance": [ + "directory user/group/membership preservation", + "existing-user password authentication in isolated environment" + ] + }, + { + "namespace": "sso", + "deployment": "authelia", + "deployment_uid": "dc40a925-2306-4a3b-89c0-e714187503c3", + "pvc_refs": [ + { + "name": "authelia-data", + "uid": "86826554-b014-4df4-8b28-866aa64f6413" + } + ], + "secret_refs": [ + "authelia-secrets", + "authelia-smtp" + ], + "configmap_refs": [ + "authelia-config" + ], + "required_assets": [ + "SQLite online-backup snapshot of /var/authelia/data/db.sqlite3 with integrity check", + "current configuration.yml and all Secret-mounted encryption/OIDC/session fields", + "explicit disposition for notifier credentials and remaining durable files" + ], + "native_acceptance": [ + "storage decryption", + "same OIDC signing identity and preserved client configuration", + "isolated authentication flow with outbound notification disabled" + ] + }, + { + "namespace": "sso", + "deployment": "keycape", + "deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f", + "pvc_refs": [ + { + "name": "keycape-authentication-policy", + "uid": "423f558f-8c11-4edf-956b-243559df4a8e" + } + ], + "secret_refs": [ + "keycape-approval-engine-operator-client", + "keycape-config", + "keycape-factor-read", + "keycape-informed-decision-sitting-requester-client", + "keycape-rapp-qonto-client", + "keycape-secrets-engine-approval-client", + "keycape-secrets-engine-openbao-client", + "keycape-secrets-engine-requester-client" + ], + "configmap_refs": [], + "required_assets": [ + "exact current keycape-config config.yaml and key.pem", + "complete stable policy/receipt PVC snapshot", + "current deployment and owner disposition for every referenced credential: encrypted recovery or independently recoverable provider reissue path" + ], + "native_acceptance": [ + "same issuer/JWKS signing identity", + "registry, authentication policy and receipt continuity", + "current consumer positive/negative authentication under isolated test endpoints" + ] + }, + { + "namespace": "mfa", + "deployment": "privacyidea", + "deployment_uid": "58c7f96d-61cb-4dd4-bca2-54661c0ac375", + "pvc_refs": [ + { + "name": "privacyidea-data", + "uid": "65650fc1-02d8-4c0f-84fc-e0015c8f3ed9" + }, + { + "name": "privacyidea-logs", + "uid": "39fa90d9-bc54-4a03-a0d6-ae85165e0cb9" + } + ], + "secret_refs": [ + "privacyidea-config" + ], + "configmap_refs": [ + "factor-recovery-01119187d90d7b76", + "keycape-onboarding-9465e5e0b98f7986", + "privacyidea-cfg" + ], + "required_assets": [ + "original /etc/privacyidea/enckey, private.pem and public.pem with matching RSA pair validation", + "current privacyidea-config and mounted configuration/scripts", + "bounded native PostgreSQL custom dump with exact database identity and key-generation linkage", + "explicit disposition for service logs; audit database/signatures must be preserved" + ], + "native_acceptance": [ + "decrypt existing retained token material without exporting it", + "verify pre-backup audit signatures and create/verify a new isolated audit entry", + "existing-token MFA and negative-key refusal" + ] + } + ], + "capture_contract": { + "plaintext": "private task staging outside Git; never process output, State Hub or logs; verified cleanup after encryption", + "consistency": "native SQLite online-backup and PostgreSQL logical snapshot; explicit capture interval per asset; no global atomic snapshot claim; before/after source identities/config versions and directory inventories must match", + "key_policy": "preserve original service keys; no age private key in the payload, no replacement generation, no root/unseal collection", + "encryption": "public-recipient-only streaming archive to age; private fresh ciphertext, no overwrite; manifest inside ciphertext with source identities, times, complete required paths, digests and omissions", + "limits": "measure first; proposed 512 MiB encrypted admission ceiling, no silent truncation/exclusions", + "historical_database": "existing verified October15 dump remains protected; new bundle must link its own exact database/key capture generation and recovery receipt" + }, + "proposed_destinations": { + "initial": "Storage Share Backup/railiance-backups/net-kingdom-identity/", + "future_secondary": "s3://railiance-platform-pg-backup/platform-pg/application-archives/net-kingdom/; not implemented/admitted in this rollout", + "admitted": false, + "publisher": "CCR-2026-0048: new folder-only read/create public DAV share, delivered by exact ESO reader; no operator password in runtime; explicitly owner-approved provider-added re-sharing (permissions21)", + "retention": "CCR-2026-0049: separate folder-only read/delete share and process; newest plus seven daily/two weekly and independently recovered pinned generation; unknown/unindexed points never deleted; explicitly owner-approved provider-added re-sharing (permissions25)", + "schedule": "source prepared: daily 03:45 UTC plus up to five minutes jitter, hourly 25-hour freshness check; installation leaves timers disabled until independent archive recovery", + "quota": "512 MiB ceiling, two GiB fresh provider headroom; initial 10 GiB account quota checked; unsupported public-share quota fails closed" + }, + "admission_gates": [ + "Approve exact CCR-2026-0048/0049 grants and node runtime packet; three required owner reviews, confirmed native SA/namespace/audience boundaries before acceptance", + "Exclude helper capture label from existing broad DNS allow policies; validate read-only helper manifests and cluster/PVC/source bindings before private capture", + "Preserve every referenced credential encrypted with original keys; fresh native SQLite/PG snapshots and exact manifest, with documented omissions and no global atomicity claim", + "Create fresh ciphertext and exact Storage Share object, read back ciphertext and receipt by strong ETag/hash, independently retrieve using operator custody", + "Attended independent age-key proof: wrong-key denial, complete archive hashes, SQLite/RSA checks, disposable native PostgreSQL restore and verified cleanup before timer activation", + "Full NK49 closure separately requires native directory/issuer/MFA authentication, retained token decryption, audit signature continuity, current callers and legacy credential dispositions; archive acceptance does not close those gates", + "Host-local health cannot report a dead host; external outage monitoring and second-provider copy remain separate coverage gaps" + ], + "runtime_host": { + "ssh_alias": "railiance01", + "hostname": "239.62.205.92.host.secureserver.net", + "machine_id_sha256": "fe7367a60951922122af7ba18ed2cde65c7e0a232bbedfb045cb575de2094c2f" + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-bundle.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-bundle.py new file mode 100644 index 00000000..3a7ea9ef --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-bundle.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Strict data-bearing identity archive format, native checks and private sealing. + +No custody key is used to create a bundle. Its manifest and plaintext digests +stay inside encryption. Public receipts expose only ciphertext provenance. +""" +import base64 +from datetime import datetime,timezone +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path,PurePosixPath +import re +import sqlite3 +import stat +import subprocess +import tarfile +import tempfile + +ROOT=Path(__file__).resolve().parents[2] +PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json' +LIMIT=512*1024*1024 +FILE_LIMIT=256*1024*1024 +NAME=re.compile(r'[A-Za-z0-9_.-]{1,160}\Z') + + +class Refused(Exception):pass + + +def profile():return json.loads(PROFILE.read_text()) + + +def private(path,directory=False,max_bytes=FILE_LIMIT): + if any(p.is_symlink() for p in (path,*path.parents)):raise Refused() + s=path.lstat() + if s.st_uid!=os.getuid() or s.st_mode&0o077:raise Refused() + if directory: + if not stat.S_ISDIR(s.st_mode):raise Refused() + elif not stat.S_ISREG(s.st_mode) or s.st_nlink!=1 or s.st_size>max_bytes:raise Refused() + + +def allowed(name,profile_data=None): + p=PurePosixPath(name) + if p.is_absolute() or any(not NAME.fullmatch(x) or x in {'.','..'} for x in p.parts):return False + if len(p.parts)>=3 and p.parts[:2] in [('data','lldap'),('data','authelia'),('data','keycape'),('data','privacyidea')]:return True + if name=='databases/privacyidea.dump':return True + for c in (profile_data or profile())['components']: + prefix=f"resources/{c['namespace']}/" + if name==prefix+'deployment/'+c['deployment']+'.json':return True + if any(name==prefix+'secret/'+n+'.json' for n in c['secret_refs']):return True + if any(name==prefix+'configmap/'+n+'.json' for n in c['configmap_refs']):return True + return False + + +def save(root,name,value,profile_data=None): + if not allowed(name,profile_data) or len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused() + target=root/name;target.parent.mkdir(mode=0o700,parents=True,exist_ok=True) + fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as out:out.write(value) + + +def import_files(raw,root,mode): + names=set();total=0 + with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive: + for member in archive: + expected=('data/privacyidea/',) if mode=='mfa' else ('data/lldap/','data/authelia/','data/keycape/') + if (not member.isfile() or not member.name.startswith(expected) or member.name in names + or member.size<0 or member.size>FILE_LIMIT or len(names)>=2048):raise Refused() + names.add(member.name);total+=member.size + if total>LIMIT:raise Refused() + data=archive.extractfile(member).read() + if len(data)!=member.size:raise Refused() + save(root,member.name,data) + + +def resource(root,ns,kind,name): + p=root/f'resources/{ns}/{kind}/{name}.json' + data=json.loads(p.read_bytes()) + if data['metadata']['name']!=name or data['metadata']['namespace']!=ns:raise Refused() + return data + + +def checks(root,profile_data=None): + # Checks describe stored formats/integrity, not current login validity. + for c in (profile_data or profile())['components']: + deployment=resource(root,c['namespace'],'deployment',c['deployment']) + if deployment.get('kind')!='Deployment' or deployment['metadata']['uid']!=c['deployment_uid']:raise Refused() + for name in c['secret_refs']: + data=resource(root,c['namespace'],'secret',name) + if data.get('kind')!='Secret' or not isinstance(data.get('data'),dict) or not data['data']:raise Refused() + for value in data['data'].values(): + if b'AGE-SECRET-KEY-' in base64.b64decode(value,validate=True):raise Refused() + for name in c['configmap_refs']: + if resource(root,c['namespace'],'configmap',name).get('kind')!='ConfigMap':raise Refused() + for name,file in [('lldap','users.db'),('authelia','db.sqlite3')]: + db=root/f'data/{name}/{file}' + with sqlite3.connect(db.as_uri()+'?mode=ro',uri=True) as con: + if con.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused() + with (root/'databases/privacyidea.dump').open('rb') as source: + if source.read(5)!=b'PGDMP':raise Refused() + spec=importlib.util.spec_from_file_location('pi_keys',ROOT/'sso-mfa/k8s/privacyidea/key-custody-remote.py') + keys=importlib.util.module_from_spec(spec);spec.loader.exec_module(keys) + keys.validate({name:(root/'data/privacyidea'/name).read_bytes() for name in ('enckey','private.pem','public.pem')}) + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + config=resource(root,'sso','secret','keycape-config')['data'] + if not base64.b64decode(config['config.yaml'],validate=True):raise Refused() + signing=serialization.load_pem_private_key(base64.b64decode(config['key.pem'],validate=True),password=None) + if not isinstance(signing,rsa.RSAPrivateKey) or signing.key_size<2048:raise Refused() + # Policy must exist, even for an empty set of receipts; do not silently + # restore an issuer without its current authentication policy. + policy=json.loads((root/'data/keycape/policy.json').read_bytes()) + if not isinstance(policy,dict):raise Refused() + return {'sqlite_integrity_passed':True,'privacyidea_key_format_and_pair_passed':True, + 'keycape_signing_key_format_passed':True,'policy_json_present':True, + 'database_custom_format_present':True,'native_identity_recovery_proven':False} + + +def inventory(root): + private(root,True);entries=[];total=0 + for path in sorted(root.rglob('*')): + if path.is_dir():private(path,True);continue + private(path);name=path.relative_to(root).as_posix() + if not allowed(name):raise Refused() + with path.open('rb') as source:value=source.read(FILE_LIMIT+1) + if len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused() + total+=len(value) + if total>LIMIT or len(entries)>=4096:raise Refused() + entries.append({'path':name,'bytes':len(value),'sha256':hashlib.sha256(value).hexdigest()}) + if not entries:raise Refused() + return entries + + +def seal(root,output,capture): + output=output.absolute();private(output.parent,True) + if output.exists() or output.is_symlink():raise Refused() + checked=checks(root);entries=inventory(root) + manifest={'schema':'netkingdom.identity-archive.v1','profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(), + 'profile':profile(), + 'captured_at':datetime.now(timezone.utc).isoformat(),'capture':capture,'files':entries,'checks':checked} + with tempfile.TemporaryDirectory(prefix='.identity-seal-',dir=output.parent) as temporary: + ciphertext=Path(temporary)/'ciphertext' + fd=os.open(ciphertext,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + proc=subprocess.Popen(['age','-r',profile()['public_recipient']],stdin=subprocess.PIPE,stdout=fd,stderr=subprocess.DEVNULL) + os.close(fd) + try: + with tarfile.open(fileobj=proc.stdin,mode='w|') as archive: + raw=json.dumps(manifest,sort_keys=True).encode();m=tarfile.TarInfo('manifest.json');m.size=len(raw);m.mode=0o600 + archive.addfile(m,io.BytesIO(raw)) + for entry in entries: + with (root/entry['path']).open('rb') as source: + value=source.read(FILE_LIMIT+1) + if hashlib.sha256(value).hexdigest()!=entry['sha256']:raise Refused() + m=tarfile.TarInfo(entry['path']);m.size=len(value);m.mode=0o600 + archive.addfile(m,io.BytesIO(value)) + proc.stdin.close() + if proc.wait(timeout=120) or not 04096:raise Refused() + seen=set();total=0 + # Iteration starts with the already-read first member; explicitly skip + # only that member. A later duplicate manifest is never accepted. + for index,member in enumerate(archive): + if index==0 and member is first:continue + name=member.name + if (not member.isfile() or name in seen or name not in expected or not allowed(name,captured) + or not 0<=member.size<=FILE_LIMIT or member.size!=expected[name]['bytes']):raise Refused() + seen.add(name);total+=member.size + if total>LIMIT:raise Refused() + raw=archive.extractfile(member).read() + if len(raw)!=member.size or hashlib.sha256(raw).hexdigest()!=expected[name]['sha256']:raise Refused() + save(root,name,raw,captured) + if seen!=set(expected):raise Refused() + return manifest,checks(root,captured) + + +def require_runtime_host(): + import socket + host=profile()['runtime_host'] + if (os.geteuid()!=0 or socket.gethostname()!=host['hostname'] + or hashlib.sha256(Path('/etc/machine-id').read_bytes()).hexdigest()!=host['machine_id_sha256']):raise Refused() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-credential-setup.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-credential-setup.py new file mode 100644 index 00000000..da49bc02 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-credential-setup.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Attended Platform/Backup owner ceremony for two exact new backup shares. + +Requires approved, hash-bound CCRs. No operator password/age key is delivered to +the runtime; only a folder-scoped share token is stored with initial KV CAS=0. +""" +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + +HERE=Path(__file__).resolve().parent +PLATFORM=HERE.parents[1].parent/'railiance-platform' +sys.path.insert(0,str(PLATFORM/'scripts')) +from state_hub_preflight_lane import bao,data,capabilities,revoke +from repair_eso_kubernetes_auth import role_payload,check_role +from telemetry_smtp_delivery import ensure + + +def load(name,path): + spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m + + +inventory=load('storage_inventory',HERE/'inventory-storage-share.py') +offsite=load('offsite',HERE/'identity-offsite.py') +FOLDER='/railiance-backups/net-kingdom-identity' +PROFILES={'publisher':(48,21),'retention':(49,25)} + + +class Refused(Exception):pass + + +def contract(action,expected): + number,permission=PROFILES[action] + path=PLATFORM/f'credential-change-requests/CCR-2026-{number:04d}-net-kingdom-backup-{action}.yaml' + raw=path.read_bytes() + if hashlib.sha256(raw).hexdigest()!=expected:raise Refused() + module=load('credential_change',PLATFORM/'scripts/credential-change.py') + ccr,errors,_=module.validate_ccr(path) + if errors or ccr['status'] not in {'approved','applied','verified','active'}:raise Refused() + required={'platform-owner','net-kingdom-owner','backup-share-owner'} + approved={r['reviewer'].removeprefix('User (').removesuffix(')') for r in ccr.get('review',{}).get('comments',[]) + if r.get('decision')=='approved' and r.get('source_ref')} + name='net-kingdom-backup-'+action+'-eso' + expected_path='platform/workloads/net-kingdom/identity-backup/'+action + a=ccr['openbao'];provider=ccr['provider'] + if (not required<=approved or a['kv_path']!=expected_path or a['fields']!=['NC_WEBDAV_TOKEN'] + or a['policy_name']!=name or a['auth']['role']!=name + or a['auth']['bound_claims']!={'service_account_names':[name],'service_account_namespaces':['sso']} + or provider['folder']!=FOLDER or provider['share_permissions']!=permission or provider['share_type']!=3 + or provider['label']!='net-kingdom-identity-'+action + or provider.get('provider_added_resharing_approved') is not True):raise Refused() + if (a.get('metadata_read') is not False or a.get('token_self_lifecycle') is not True + or a['auth'].get('audience')!='openbao' or a['auth'].get('token_no_default_policy') is not True + or any(a['auth'].get(k)!='15m' for k in ['ttl','token_max_ttl','token_explicit_max_ttl']) + or a['auth'].get('policies')!=[name] or provider.get('host')!='nx4069.your-storageshare.de'):raise Refused() + lane={'service_account':name,'namespace':'sso','role':name,'policy':name, + 'kv_path':expected_path.replace('platform/','platform/data/',1)} + wanted=('path "'+lane['kv_path']+'" { capabilities = ["read"] }\n' + 'path "auth/token/lookup-self" { capabilities = ["read"] }\n' + 'path "auth/token/revoke-self" { capabilities = ["update"] }\n') + if (PLATFORM/a['policy_file']).read_text()!=wanted:raise Refused() + return ccr,lane + + +def request(client,url,auth,method='GET',body=None,extra=None): + if not url.startswith(offsite.HOST+'/'):raise Refused() + h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode(), + 'X-Requested-With':'XMLHttpRequest'};h.update(extra or {}) + try: + with client.open(urllib.request.Request(url,data=body,method=method,headers=h),timeout=60) as response: + if response.url!=url:raise Refused() + raw=response.read(1024*1024+1) + if len(raw)>1024*1024:raise Refused() + return response.status,raw + except urllib.error.HTTPError as error:return error.code,b'' + + +def ocs(client,auth,suffix='',fields=None): + url=offsite.HOST+'/ocs/v2.php/apps/files_sharing/api/v1/shares'+suffix + body=urllib.parse.urlencode(fields).encode() if fields is not None else None + status,raw=request(client,url,auth,'POST' if fields is not None else 'GET',body, + {'OCS-APIRequest':'true','Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'}) + if status not in (200,201):raise Refused() + parsed=json.loads(raw)['ocs'] + if parsed['meta']['statuscode'] not in (100,200):raise Refused() + return parsed['data'] + + +def share(client,auth,action,receipt): + owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER + status,_=request(client,owner,auth,'MKCOL') + if status not in (201,405):raise Refused() + receipt['folder_created']=status==201 + matches=[s for s in ocs(client,auth,'?path='+urllib.parse.quote(FOLDER,safe='')) + if s.get('label')=='net-kingdom-identity-'+action] + if len(matches)>1:raise Refused() + if matches:result=matches[0];receipt['share_created']=False + else: + result=ocs(client,auth,fields={'path':FOLDER,'shareType':3,'permissions':PROFILES[action][1], + 'label':'net-kingdom-identity-'+action});receipt['share_created']=True + if (result.get('path')!=FOLDER or int(result.get('permissions',-1))!=PROFILES[action][1] + or int(result.get('share_type',-1))!=3 or not result.get('token')):raise Refused() + offsite.headers(result['token']) + receipt.update(share_id=str(result['id']),share_permissions=int(result['permissions']),folder=FOLDER) + return result['token'] + + +def boundary(client,auth,token,action,receipt): + name='nk49-boundary-'+uuid.uuid4().hex+'.age' + payload=subprocess.run(['age','-r',json.loads((HERE/'identity-backup-profile.json').read_text())['public_recipient']], + input=b'NK49 disposable provider boundary fixture',stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10) + if payload.returncode:raise Refused() + owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+name + public=offsite.BASE+name;created=False;deleted=False + receipt['provider_test_artifacts']=[name] + receipt['boundary_fixture_cleanup_verified']=False + try: + receipt['boundary_phase']='create_owner_fixture' + status,_=request(client,owner,auth,'PUT',payload.stdout,{'If-None-Match':'*'}) + if status!=201:raise Refused() + created=True + receipt['boundary_phase']='read_public_fixture' + status,raw=request(client,public,(token,'')) + if status!=200 or raw!=payload.stdout:raise Refused() + receipt['boundary_phase']='deny_public_overwrite' + status,_=request(client,public,(token,''),'PUT',payload.stdout) + if status not in (401,403):raise Refused() + denied_name='nk49-absent-'+uuid.uuid4().hex+'.age' + if action=='retention':receipt['provider_test_artifacts'].append(denied_name) + status,_=request(client,offsite.BASE+denied_name,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'}) if action=='retention' else (403,b'') + if status not in (401,403):raise Refused() + receipt['boundary_phase']='public_delete' + status,_=request(client,public,(token,''),'DELETE') + if action=='publisher': + if status not in (401,403):raise Refused() + new=offsite.BASE+'nk49-boundary-'+uuid.uuid4().hex+'.age' + receipt['provider_test_artifacts'].append(new.rsplit('/',1)[-1]) + receipt['boundary_phase']='create_public_fixture' + code,_=request(client,new,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'}) + if code!=201:raise Refused() + # This fixture belongs to the current ceremony, not retained data. + receipt['boundary_phase']='cleanup_public_fixture' + remove_fixture(client,offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+new.rsplit('/',1)[-1],auth,payload.stdout) + else: + if status!=204:raise Refused() + deleted=True + receipt['boundary_phase']='deny_parent_path' + status,_=request(client,offsite.BASE+'%2e%2e/r01-net-kingdom-pg/',(token,'')) + receipt['parent_probe_status']=status + if status not in (400,401,403,404):raise Refused() + receipt['parent_path_rejected']=True + # A malformed-path400 proves request rejection, not authorization. + # Separately probe a known existing account folder with only this share + # token: it must not authenticate as the Backup account. + receipt['boundary_phase']='deny_other_account_folder' + status,_=request(client,offsite.HOST+'/remote.php/dav/files/Backup/r01-net-kingdom-pg/',(token,'')) + receipt['other_account_folder_status']=status + if status not in (401,403,404):raise Refused() + receipt['provider_boundary_verified']=True + finally: + if created and not deleted: + remove_fixture(client,owner,auth,payload.stdout) + receipt['owner_fixture_cleanup_verified']=True + # A rejected operation may have had an unexpected side effect. Only a + # successful complete boundary test claims cleanup; names remain in the + # receipt for owner reconciliation on every failure. + receipt['boundary_fixture_cleanup_verified']=receipt.get('provider_boundary_verified',False) + + +def remove_fixture(client,url,auth,expected): + h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode()} + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response: + version=offsite.etag(response,url,len(expected)) + code,raw=request(client,url,auth,extra={'If-Match':version}) + if code!=200 or raw!=expected:raise Refused() + code,_=request(client,url,auth,'DELETE',extra={'If-Match':version}) + if code!=204:raise Refused() + + +def run(action,expected,receipt): + ccr,lane=contract(action,expected) + identity,credentials=inventory.caller_credentials() + receipt['caller_entity_id']=identity.get('entity_id');receipt['caller_auth_path']=identity['path'] + client=offsite.opener();auth=(credentials['BACKUP_USERNAME'],credentials['BACKUP_PASSWORD']) + # Use the owner quota accessor only with proxies removed from its environment. + quota=load('owner_quota',PLATFORM/'scripts/migrate_nextcloud_backup_account.py') + quota.request=lambda url,method='GET',body=None,auth=None,headers=None:request(client,url,auth,method,body,headers) + receipt['quota_before']=quota.quota(auth) + if receipt['quota_before']['available_bytes']MAX_FILE:raise Refused() + with os.fdopen(os.dup(fd),'rb') as stream:data=stream.read(MAX_FILE+1) + after=os.stat(path,follow_symlinks=False) + if (len(data)>MAX_FILE or b'AGE-SECRET-KEY-' in data or + any(getattr(before,k)!=getattr(after,k) for k in ('st_dev','st_ino','st_size','st_mtime_ns','st_ctime_ns'))):raise Refused() + return data + finally:os.close(fd) + + +def tree(root,database=None,omit_notification=False,omit_backups=True): + result={} + for directory,dirs,files in os.walk(root,followlinks=False): + path=Path(directory) + if path.is_symlink():raise Refused() + # Historical local snapshots are redundant; do not recursively include + # accumulated backups. WAL/SHM are represented by the native snapshot. + if omit_backups:dirs[:]=[name for name in dirs if name!='backups'] + for name in dirs: + if (path/name).is_symlink():raise Refused() + for name in files: + relative=(path/name).relative_to(root).as_posix() + if database and relative in {database,database+'-wal',database+'-shm',database+'-journal'}:continue + if omit_notification and relative=='notification.txt':continue + result[relative]=read(path/name) + if len(result)>MAX_COUNT or sum(map(len,result.values()))>MAX_TOTAL:raise Refused() + return result + + +def snapshot(source,target): + if source.is_symlink() or not source.is_file():raise Refused() + # A read-only URI intentionally fails if the engine needs a write to its + # production WAL/SHM. Never fall back to a raw copy or mutate the mount. + with sqlite3.connect(source.as_uri()+'?mode=ro',uri=True,timeout=10) as origin: + with sqlite3.connect(target) as dest: + origin.backup(dest,pages=128,sleep=0.05) + if dest.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused() + return read(target) + + +def capture(mode,root=Path('/source'),scratch=Path('/scratch')): + values={} + with tempfile.TemporaryDirectory(prefix='snapshot-',dir=scratch) as temporary: + if mode=='sso': + for name,database in [('lldap','users.db'),('authelia','db.sqlite3'),('keycape',None)]: + source=root/name + if source.is_symlink() or not source.is_dir():raise Refused() + first=tree(source,database,name=='authelia',name!='keycape') + if database:first[database]=snapshot(source/database,Path(temporary)/(name+'.sqlite')) + second=tree(source,database,name=='authelia',name!='keycape') + if {k:v for k,v in first.items() if k!=database}!=second:raise Refused() + values.update({f'data/{name}/{k}':v for k,v in first.items()}) + elif mode=='mfa': + first=tree(root/'privacyidea') + if not {'enckey','private.pem','public.pem'}<=set(first) or first!=tree(root/'privacyidea'):raise Refused() + values={f'data/privacyidea/{name}':value for name,value in first.items()} + else:raise Refused() + if len(values)>MAX_COUNT or sum(map(len,values.values()))>MAX_TOTAL:raise Refused() + return values + + +def emit(values,destination): + with tarfile.open(fileobj=destination,mode='w|') as archive: + for name,value in sorted(values.items()): + member=tarfile.TarInfo(name);member.size=len(value);member.mode=0o600 + archive.addfile(member,io.BytesIO(value)) + + +def interrupted(*args):raise Refused() + + +if __name__=='__main__': + for sig in (signal.SIGTERM,signal.SIGHUP,signal.SIGINT):signal.signal(sig,interrupted) + try: + os.umask(0o077) + if len(sys.argv)!=2:raise Refused() + emit(capture(sys.argv[1]),sys.stdout.buffer) + except Exception: + print('ERROR: private file capture refused; diagnostics suppressed.',file=sys.stderr) + raise SystemExit(1) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-isolation.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-isolation.py new file mode 100644 index 00000000..c19a2723 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-isolation.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Plan two exact DNS-selector exclusions; apply only an owner-admitted hash-bound plan.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path + +HERE=Path(__file__).resolve().parent +spec=importlib.util.spec_from_file_location('capture',HERE/'capture-identity.py') +capture=importlib.util.module_from_spec(spec);spec.loader.exec_module(capture) +SELECTOR={'matchExpressions':[{'key':'net-kingdom/capture','operator':'DoesNotExist'}]} + + +def plan(): + if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise capture.Refused() + rows=[] + for ns in ('sso','mfa'): + obj=capture.get(ns,'networkpolicy','allow-egress-dns');s=obj['spec'];m=obj['metadata'] + if s['podSelector'] not in ({},SELECTOR):raise capture.Refused() + pods=json.loads(capture.command(['-n',ns,'get','pods','-o','json']))['items'] + if any('net-kingdom/capture' in p['metadata'].get('labels',{}) for p in pods):raise capture.Refused() + # Preserve every peer, port and policy type. Existing unlabelled service + # pods retain their exact selector match. UID/RV/spec tests guard drift. + patch=[{'op':'test','path':'/metadata/uid','value':m['uid']}, + {'op':'test','path':'/metadata/resourceVersion','value':m['resourceVersion']}, + {'op':'test','path':'/spec','value':s}, + {'op':'replace','path':'/spec/podSelector','value':SELECTOR}] + rows.append({'namespace':ns,'name':m['name'],'uid':m['uid'],'patch':patch}) + return {'schema':'netkingdom.identity-isolation.v1','status':'prepared_not_applied', + 'kubeconfig':capture.KUBE[2],'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'policies':rows} + + +def apply(packet,receipt,dry_run=False): + if packet!=plan():raise capture.Refused() + for row in packet['policies']: + args=['-n',row['namespace'],'patch','networkpolicy',row['name'],'--type=json','-p',json.dumps(row['patch']),'-o','json'] + if dry_run:args+=['--dry-run=server'] + receipt['attempted'].append({'namespace':row['namespace'],'name':row['name'],'uid':row['uid']}) + actual=json.loads(capture.command(args));expected=row['patch'][2]['value'].copy();expected['podSelector']=SELECTOR + if actual['metadata']['uid']!=row['uid'] or actual['spec']!=expected:raise capture.Refused() + receipt['verified'].append({'namespace':row['namespace'],'name':row['name'],'uid':row['uid']}) + receipt['status']='server_dry_run_passed' if dry_run else 'capture_dns_exclusions_verified' + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=['plan','dry-run','apply']) + p.add_argument('--kubeconfig',required=True);p.add_argument('--plan');p.add_argument('--plan-sha256');p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077);capture.KUBE=['kubectl','--kubeconfig',a.kubeconfig] + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'status':'failed','attempted':[],'verified':[]} + try: + if a.action=='plan':r=plan() + else: + raw=Path(a.plan).read_bytes() + if hashlib.sha256(raw).hexdigest()!=a.plan_sha256:raise capture.Refused() + apply(json.loads(raw),r,a.action=='dry-run') + except Exception:r.update(status='failed',error='isolation_refused_review_partial_attempts') + finally: + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-offsite.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-offsite.py new file mode 100644 index 00000000..33707e5a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-offsite.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Folder-scoped Storage Share ciphertext publication and verified readback. + +Runtime gets only a dedicated read/create share token. Account administration, +deletion and age private keys have no route in this publisher. +""" +import base64 +from datetime import datetime,timezone +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +HOST='https://nx4069.your-storageshare.de' +BASE=HOST+'/public.php/webdav/' +NAME=re.compile(r'net-kingdom-identity-\d{8}T\d{6}Z-[a-f0-9]{12}\.tar\.age\Z') +LIMIT=512*1024*1024 +HEADROOM=2*1024**3 + + +class Refused(Exception):pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):raise Refused() + + +def opener():return urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect()) + + +def headers(token): + if not isinstance(token,str) or not re.fullmatch('[A-Za-z0-9]{10,128}',token):raise Refused() + return {'Authorization':'Basic '+base64.b64encode((token+':').encode()).decode(), + 'X-Requested-With':'XMLHttpRequest','Accept-Encoding':'identity'} + + +def digest(path): + with path.open('rb') as source:return hashlib.file_digest(source,'sha256').hexdigest() + + +def quota(client,token): + body=b'' + h=headers(token);h.update(Depth='0');h['Content-Type']='application/xml' + with client.open(urllib.request.Request(BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response: + if response.status!=207 or response.url!=BASE:raise Refused() + raw=response.read(1024*1024+1) + if len(raw)>1024*1024:raise Refused() + amount=ET.fromstring(raw).findtext('.//{DAV:}quota-available-bytes') or '' + if not amount.isdigit():raise Refused() + return int(amount) + + +def etag(response,url,size): + value=response.headers.get('ETag','') + if (response.status!=200 or response.url!=url or response.headers.get('Content-Length')!=str(size) + or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',value)):raise Refused() + return value + + +def readback(client,token,name,size,expected,target): + valid=NAME.fullmatch(name) or (name.endswith('.receipt.json') and NAME.fullmatch(name.removesuffix('.receipt.json')) and size<=65536) + if not valid or not 0size:raise Refused() + sha.update(block);out.write(block) + out.flush();os.fsync(out.fileno()) + if count!=size or sha.hexdigest()!=expected:raise Refused() + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response: + if etag(response,url,size)!=version:raise Refused() + return version + + +def publish(client,token,source,capture,target): + if (not NAME.fullmatch(source.name) or source.is_symlink() or source.stat().st_mode&0o077 + or source.stat().st_uid!=os.getuid() or source.stat().st_nlink!=1 or not source.is_file() + or source.stat().st_size>LIMIT or capture.get('status')!='encrypted_bundle_created' + or capture.get('helper_cleanup_verified') is not True or capture.get('temporary_plaintext_cleanup_verified') is not True + or digest(source)!=capture.get('ciphertext_sha256') or source.stat().st_size!=capture.get('ciphertext_bytes')):raise Refused() + with source.open('rb') as stream: + if stream.read(22)!=b'age-encryption.org/v1\n':raise Refused() + available=quota(client,token) + if available' + with client.open(urllib.request.Request(offsite.BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response: + if response.status!=207 or response.url!=offsite.BASE:raise offsite.Refused() + raw=response.read(4*1024*1024+1) + if len(raw)>4*1024*1024:raise offsite.Refused() + result={} + for response in ET.fromstring(raw).findall('{DAV:}response'): + href=urllib.parse.urlsplit(response.findtext('{DAV:}href') or '') + if href.netloc and href.netloc!=urllib.parse.urlsplit(offsite.HOST).netloc:raise offsite.Refused() + name=urllib.parse.unquote(href.path.rstrip('/').rsplit('/',1)[-1]) + for row in response.findall('{DAV:}propstat'): + if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue + p=row.find('{DAV:}prop') + if p is None or p.find('{DAV:}resourcetype/{DAV:}collection') is not None:continue + is_index=name.endswith('.receipt.json') and offsite.NAME.fullmatch(name.removesuffix('.receipt.json')) + if not offsite.NAME.fullmatch(name) and not is_index:continue + size=p.findtext('{DAV:}getcontentlength') or '';version=p.findtext('{DAV:}getetag') or '' + if not size.isdigit() or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',version) or name in result:raise offsite.Refused() + result[name]={'bytes':int(size),'etag':version} + if len(result)>10000:raise offsite.Refused() + return result + + +def generations(client,token,objects): + result=[] + for name,meta in objects.items(): + if not offsite.NAME.fullmatch(name) or name+'.receipt.json' not in objects:continue + index=objects[name+'.receipt.json'] + if not 03600:raise offsite.Refused() + if stamp>datetime.now(timezone.utc):raise offsite.Refused() + result.append({**r,'stamp':stamp,'etag':meta['etag'],'index_etag':index['etag']}) + return sorted(result,key=lambda r:r['stamp'],reverse=True) + + +def select(rows,protected): + if not rows or protected not in {r['artifact'] for r in rows}:raise offsite.Refused() + keep={protected,rows[0]['artifact']};days=set();weeks=set() + for row in rows: + day=row['stamp'].date();week=row['stamp'].isocalendar()[:2] + if day not in days and len(days)<7:keep.add(row['artifact']);days.add(day) + if week not in weeks and len(weeks)<2:keep.add(row['artifact']);weeks.add(week) + return [r for r in rows if r['artifact'] not in keep],keep + + +def run(client,token,accepted,receipt,execute=False,checkpoint=lambda:None): + if (accepted.get('status')!='identity_archive_verified' or accepted.get('container_cleanup_verified') is not True + or accepted.get('temporary_plaintext_cleanup_verified') is not True or not offsite.NAME.fullmatch(accepted.get('artifact',''))):raise offsite.Refused() + objects=listing(client,token);rows=generations(client,token,objects) + candidates,keep=select(rows,accepted['artifact']) + recovered=next(r for r in rows if r['artifact']==accepted['artifact']) + if recovered['ciphertext_sha256']!=accepted['ciphertext_sha256']:raise offsite.Refused() + receipt.update(status='retention_preview',protected=accepted['artifact'],keep=sorted(keep), + candidates=[r['artifact'] for r in candidates],deleted=[],unknown_objects_untouched=True) + checkpoint() + # Validate retained known-good and newest ciphertext before any deletion. + with tempfile.TemporaryDirectory(prefix='nk49-retained-check-',dir='/tmp') as temporary: + for index,row in enumerate({r['artifact']:r for r in [recovered,rows[0]]}.values()): + offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/str(index)) + if not execute:return + for index,row in enumerate(candidates): + version=offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/('expire-'+str(index))) + for name,etag in [(row['artifact'],version),(row['artifact']+'.receipt.json',row['index_etag'])]: + h=offsite.headers(token);h['If-Match']=etag + receipt['deletion_in_progress']=name + checkpoint() + with client.open(urllib.request.Request(offsite.BASE+name,method='DELETE',headers=h),timeout=30) as response: + if response.status!=204 or response.url!=offsite.BASE+name:raise offsite.Refused() + receipt['deleted'].append(name) + checkpoint() + receipt.pop('deletion_in_progress',None) + receipt['status']='retention_applied' diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-runtime.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-runtime.py new file mode 100644 index 00000000..eb0b6014 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/identity-runtime.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Root-owned Railiance backup job. No operator password or age private key. + +Publisher and retention are separate processes with separate scoped delivery. +Scheduled execution is gated by an independently recovered archive receipt. +""" +import argparse +import base64 +from datetime import datetime,timezone +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import signal +import shutil +import socket +import tempfile +import urllib.request + +HERE=Path(__file__).resolve().parent + + +def load(name): + spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py')) + m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m + + +capture=load('capture-identity');offsite=load('identity-offsite');retention=load('identity-retention') + + +def write(path,value): + fd,name=tempfile.mkstemp(prefix='.identity-receipt-',dir=path.parent) + try: + with os.fdopen(fd,'w') as out:json.dump(value,out,indent=2);out.write('\n');out.flush();os.fsync(out.fileno()) + os.replace(name,path) + finally:Path(name).unlink(missing_ok=True) + + +def token(action): + name='net-kingdom-backup-'+action + es=capture.get('sso','externalsecret',name) + entries=[] + for entry in es['spec'].get('data',[]): + item=dict(entry);ref=dict(item.get('remoteRef',{})) + for key,default in [('conversionStrategy','Default'),('decodingStrategy','None'),('metadataPolicy','None')]: + if ref.pop(key,default)!=default:raise offsite.Refused() + item['remoteRef']=ref;entries.append(item) + if (es['spec']['target']['name']!=name or entries!=[{'secretKey':'token','remoteRef':{ + 'key':'workloads/net-kingdom/identity-backup/'+action,'property':'NC_WEBDAV_TOKEN'}}] + or es['spec'].get('dataFrom') or es['spec'].get('secretStoreRef')!={'kind':'SecretStore','name':name} + or not any(c['type']=='Ready' and c['status']=='True' for c in es['status'].get('conditions',[]))):raise offsite.Refused() + secret=capture.get('sso','secret',name) + if (set(secret['data'])!={'token'} or not any(o['uid']==es['metadata']['uid'] for o in secret['metadata'].get('ownerReferences',[]))):raise offsite.Refused() + value=base64.b64decode(secret['data']['token'],validate=True).decode();offsite.headers(value) + return value + + +def accepted(path): + capture.bundle.private(path) + r=json.loads(path.read_text()) + if (r.get('schema')!='netkingdom.identity-recovery.v1' or r.get('status')!='identity_archive_verified' + or r.get('all_manifest_hashes_verified') is not True or r.get('container_cleanup_verified') is not True + or r.get('temporary_plaintext_cleanup_verified') is not True or not offsite.NAME.fullmatch(r.get('artifact',''))):raise offsite.Refused() + return r + + +def post_event(state,summary): + # Non-secret durable reporting: replay retained notes at the next run. + directory=state/'event-outbox';directory.mkdir(mode=0o700,exist_ok=True) + import uuid + path=directory/(uuid.uuid4().hex+'.json') + write(path,{'summary':summary,'event_type':'note','author':'net-kingdom-identity-backup', + 'workplan_id':'17d315f0-829e-57c7-85cc-3a7914152804','task_id':'d68cd4d3-1ba3-530e-88f9-26ebb8dd6eac'}) + for pending in sorted(directory.glob('*.json'))[:64]: + request=urllib.request.Request('http://10.43.68.154:8000/progress/',data=pending.read_bytes(), + headers={'Content-Type':'application/json'},method='POST') + try: + with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(request,timeout=10) as response: + if response.status!=201:break + pending.unlink() + except Exception:break + + +def backup(state,decision,first=False): + if not first:accepted(state/'accepted-recovery.json') + if (state/'capture-in-progress.json').exists():raise offsite.Refused() + publisher=token('publisher') + client=offsite.opener() + if offsite.quota(client,publisher)=64:raise Refused() + row=list_prefix(client,prefix);result[prefix]=row + if depth<3: + for child in row['directories']: + # Native database WAL/base trees are not application/key-file + # archives. Record their presence without enumerating every WAL. + if any(x in child.split('/') for x in ['wals','base']):continue + pending.append((child,depth+1)) + return result + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True) + a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.scaleway-backup-inventory.v1','status':'failed', + 'started_at':datetime.now(timezone.utc).isoformat(),'read_only':True, + 'provider':'Scaleway','bucket':BUCKET,'prefix':PREFIX,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'bootstrap_api_key_used':False,'age_keys_read':False,'object_contents_downloaded':False, + 'secret_values_exported':False,'scope':'Bounded prefix metadata, at most three child levels; WAL/base trees not enumerated; other buckets/prefixes unassessed'} + try: + if kube(['get','namespace','kube-system'])['metadata']['uid']!=CLUSTER:raise Refused() + delivered=kube(['-n','databases','get','secret','platform-pg-backup-s3']) + if delivered['metadata']['namespace']!='databases' or delivered['metadata']['name']!='platform-pg-backup-s3':raise Refused() + values=delivered['data'] + # Same governed runtime delivery used by Platform's archive verifier; + # explicit credentials avoid SDK ambient/profile/metadata discovery. + import boto3 + from botocore.config import Config + client=boto3.client('s3',endpoint_url=ENDPOINT,region_name='nl-ams', + aws_access_key_id=base64.b64decode(values['ACCESS_KEY_ID'],validate=True).decode(), + aws_secret_access_key=base64.b64decode(values['ACCESS_SECRET_KEY'],validate=True).decode(), + config=Config(signature_version='s3v4',proxies={},connect_timeout=15,read_timeout=30, + retries={'max_attempts':1},s3={'addressing_style':'path'})) + r['prefixes']=inventory(client);r['status']='metadata_inventory_complete' + r['runtime_delivery_uid']=delivered['metadata']['uid'] + except Exception:r['error']='inventory_failed_diagnostics_suppressed' + finally: + r['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/inventory-storage-share.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/inventory-storage-share.py new file mode 100644 index 00000000..fdcdfb6b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/inventory-storage-share.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Silent read-only inventory in the existing Platform Backup-account procedure. + +Runs only inside the attended platform-admin envelope. Reuses Platform's owner +custody accessor and fixed provider/account contract; no age key is read and no +backup is downloaded, uploaded, deleted, rotated or granted. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import sys +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET + +ROOT=Path(__file__).resolve().parents[2] +OWNER=ROOT.parent/'railiance-platform/scripts' +sys.path.insert(0,str(OWNER)) +from state_hub_preflight_lane import bao, data +from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH +BASE='/remote.php/dav/files/Backup/railiance-backups/' +LIMIT=4*1024*1024 +NAME=re.compile(r'[A-Za-z0-9._-]{1,160}\Z') + + +class Refused(Exception):pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):raise Refused() + + +def parse_listing(raw, relative): + if len(raw)>LIMIT:raise Refused() + rows=[] + for item in ET.fromstring(raw).findall('{DAV:}response'): + href=urllib.parse.urlsplit(item.findtext('{DAV:}href') or '') + if href.netloc and href.netloc!=urllib.parse.urlsplit(HOST).netloc:raise Refused() + path=urllib.parse.unquote(href.path) + if not path.startswith(BASE+relative):raise Refused() + name=path[len(BASE+relative):].rstrip('/') + if not name:continue + if not NAME.fullmatch(name) or name in {'.','..'}:raise Refused() + for row in item.findall('{DAV:}propstat'): + if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue + prop=row.find('{DAV:}prop') + if prop is None:continue + folder=prop.find('{DAV:}resourcetype/{DAV:}collection') is not None + size=prop.findtext('{DAV:}getcontentlength') + if size is not None and not size.isdigit():raise Refused() + # Export only known-safe operational names/size, not arbitrary DAV + # properties, URLs, account metadata or raw provider responses. + if folder or name.endswith(('.age','.json','.yaml')): + rows.append({'name':name,'directory':folder,'bytes':int(size) if size else None}) + if len(rows)>10000 or len({r['name'] for r in rows})!=len(rows):raise Refused() + return sorted(rows,key=lambda x:x['name']) + + +def listing(relative,credentials): + if relative and (not relative.endswith('/') or not NAME.fullmatch(relative[:-1])):raise Refused() + auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode() + req=urllib.request.Request(HOST+BASE+relative, + data=b'', + method='PROPFIND',headers={'Authorization':'Basic '+auth,'Depth':'1','Content-Type':'application/xml'}) + opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect()) + with opener.open(req,timeout=30) as response: + if response.status!=207 or response.url!=HOST+BASE+relative:raise Refused() + raw=response.read(LIMIT+1) + return parse_listing(raw,relative) + + +def caller_credentials(): + # Lookup payload contains a token: retain only safe caller/role facts. + identity=data(bao(['token','lookup','-format=json']))['data'] + if 'platform-admin' not in identity.get('policies',[]) or 'root' in identity.get('policies',[]):raise Refused() + if not str(identity.get('path','')).startswith('auth/netkingdom/'):raise Refused() + credentials=data(bao(['read','-format=json',OPERATOR_PATH]))['data']['data'] + if credentials.get('BACKUP_USERNAME')!='Backup' or not isinstance(credentials.get('BACKUP_PASSWORD'),str) or not credentials['BACKUP_PASSWORD']:raise Refused() + return identity,credentials + + +def inventory(): + identity,credentials=caller_credentials() + root=listing('',credentials) + folders={} + for row in root: + if row['directory'] and re.match(r'^(?:r01-|core-|identity|sso|bootstrap|net-kingdom|openbao)',row['name']): + if len(folders)>=32:raise Refused() + folders[row['name']]=listing(row['name']+'/',credentials) + return {'root':root,'folders':folders,'caller_auth_path':identity['path'], + 'caller_entity_id':identity.get('entity_id'),'scope':'Backup/railiance-backups root and bounded identity/database subfolders; other accounts/folders unassessed'} + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True) + a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.storage-share-inventory.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(), + 'read_only':True,'age_keys_read':False,'backup_contents_downloaded':False,'secret_values_exported':False, + 'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'owner_sources':[{'path':str(OWNER/name),'sha256':hashlib.sha256((OWNER/name).read_bytes()).hexdigest()} for name in ['state_hub_preflight_lane.py','migrate_nextcloud_backup_account.py']]} + try:r.update(inventory());r['status']='metadata_inventory_complete' + except Exception:r['error']='inventory_failed_diagnostics_suppressed' + finally: + r['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-keycape-session.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-keycape-session.py new file mode 100644 index 00000000..bae08768 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-keycape-session.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +"""Owner-run T18 session assembly using the existing archive/containment pipeline. + +Candidate only: native pinned-image tests and review are required before use. +Shared Docker local-driver tmpfs is required because Docker cp does not support +private tmpfs/mount paths, and the original KeyCape image is distroless. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import re +import stat +import select +import subprocess +import sys +import tempfile +import time +import uuid + +HERE=Path(__file__).resolve().parent + +def load(name): + spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py')) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module + +adapter=load('isolated-keycape') +local=load('isolated-lldap') +bundle=local.bundle +# Bind the reused preflight itself, not only this module's direct Docker calls. +local.command=adapter.command +_original_preflight=local.preflight +def preflight(): + contexts=json.loads(adapter.command(['docker','context','inspect'])) + if len(contexts)!=1 or contexts[0]['Endpoints']['docker']['Host']!=adapter.ENDPOINT:raise adapter.Refused() + result=_original_preflight();result['docker_endpoint']=adapter.ENDPOINT;return result +local.preflight=preflight +WORKER=local.CLIENT +STAGING_ROOT=Path('/dev/shm') +RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls') +VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} + +IMPORT=r''' +import base64,json,os,sys +from pathlib import Path +os.umask(0o077) +p=json.load(sys.stdin) +allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') +for name,encoded in p.items(): + f=Path(name) + if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError() + f.parent.mkdir(mode=0o700,parents=True,exist_ok=True) + fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True)) +''' + +# Small transport inside the already pinned helper; it has only loopback routes. +# No token handling, auth bypass, dependency imitation, reset or notifier path. +TLS=r''' +import select,socket,socketserver,ssl,threading +context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.minimum_version=ssl.TLSVersion.TLSv1_2 +context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem') +class Handler(socketserver.BaseRequestHandler): + def handle(self): + try: + with context.wrap_socket(self.request,server_side=True) as incoming: + with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing: + incoming.settimeout(15);outgoing.settimeout(15) + while True: + ready,_,_=select.select([incoming,outgoing],[],[],15) + if not ready:return + for source in ready: + data=source.recv(65536) + if not data:return + (outgoing if source is incoming else incoming).sendall(data) + except Exception:pass +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address=True + daemon_threads=True +with Server(('127.0.0.1',443),Handler) as server:server.serve_forever() +''' + + +def run(argv,**kwargs):return adapter.command(argv,**kwargs) + + +def plan_session(): + run_id=uuid.uuid4().hex + stem='nk49-kc-'+run_id + worker=stem+'-transport';issuer=stem+'-issuer' + volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)} + return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer, + 'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes, + 'public_image_probe':stem+'-image-probe','docker_endpoint':adapter.ENDPOINT} + + +def verify_volume(name,spec,run_id): + obj=json.loads(run(['docker','volume','inspect',name])) + if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local' + or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS + or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused() + + +def base_args(session,name,network): + return ['docker','run','--detach','--pull=never','--name',name,'--network',network, + '--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL', + '--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1', + '--pids-limit','64','--log-driver','none','--restart','no', + '--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'] + + +def mounts(session,readonly=False): + args=[] + for name,spec in session['volumes'].items(): + args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly and spec['target']!='/var/lib/keycape-policy' else '')] + return args + + +def provision(session,payload,tls,issuer): + """Inspect containment and driver options before the first private import.""" + from urllib.parse import urlsplit + address=urlsplit(issuer) + if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'): + raise adapter.Refused() + for name,spec in session['volumes'].items(): + run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'], + '--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name]) + verify_volume(name,spec,session['run_id']) + args=base_args(session,session['worker'],'none')+mounts(session) + args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1', + '--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)'] + run(args) + # Check helper and every private filesystem before touching the ciphertext's + # private contents. Issuer is created only after originals are present. + adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True) + files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}} + run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode()) + anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip() + args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True) + # CLI argv carries environment names only. Original credentials stay in the + # owner subprocess environment and Docker's private container configuration. + env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'], + 'SSL_CERT_FILE':'/recovery-tls/trust.pem'} + for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name] + args+=[adapter.IMAGE] + run(args,env=env) + adapter.inspect_session(session) + run(['docker','exec','--detach',session['worker'],'python3','-c',TLS]) + + +def cleanup_all(session,receipt): + failed=False + try:adapter.cleanup({**session,'containers':session['containers']+[session['public_image_probe']]},receipt) + except Exception:failed=True + rows=[] + for name,spec in session['volumes'].items(): + row={'volume':name,'status':'unverified'};rows.append(row) + try: + exists=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() + if not exists: + row['status']='absence_verified' + continue + verify_volume(name,spec,session['run_id']) + run(['docker','volume','rm',name]) + remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() + if remaining:row['status']='still_present';failed=True + else:row['status']='absence_verified' + except Exception:failed=True + receipt['tmpfs_volume_cleanup_obligations']=rows + receipt['container_cleanup_verified']=not failed + if failed:raise adapter.Refused() + +def register_workdir(work, run_id): + if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+run_id+'-[a-z0-9_]{8}',work.name):raise adapter.Refused() + if any(p.is_symlink() for p in (work,*work.parents)):raise adapter.Refused() + info=work.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)!=0o700:raise adapter.Refused() + return {'path':str(work),'run_id':run_id,'device':info.st_dev,'inode':info.st_ino,'owner_uid':info.st_uid} + + +def cleanup_workdir(staging, receipt): + """Delete only the registered inode below actual /dev/shm, never a replacement.""" + row={'path':staging['path'],'device':staging['device'],'inode':staging['inode'],'status':'unverified'} + receipt['plaintext_cleanup_obligation']=row + work=Path(staging['path']) + if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+staging['run_id']+'-[a-z0-9_]{8}',work.name):raise adapter.Refused() + if not any(line.split()[1:3]==['/dev/shm','tmpfs'] for line in Path('/proc/mounts').read_text().splitlines()):raise adapter.Refused() + if any(p.is_symlink() for p in work.parents):raise adapter.Refused() + parentfd=os.open(work.parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW) + try: + try:info=os.stat(work.name,dir_fd=parentfd,follow_symlinks=False) + except FileNotFoundError: + row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return + if (not stat.S_ISDIR(info.st_mode) or info.st_uid!=staging['owner_uid'] or info.st_uid!=os.getuid() + or info.st_dev!=staging['device'] or info.st_ino!=staging['inode'] or stat.S_IMODE(info.st_mode)!=0o700): + row['status']='identity_mismatch_not_removed';raise adapter.Refused() + def remove(parent,name,expected): + fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parent) + try: + current=os.fstat(fd) + if (current.st_dev,current.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused() + for child in os.listdir(fd): + before=os.stat(child,dir_fd=fd,follow_symlinks=False) + if before.st_uid!=staging['owner_uid'] or before.st_dev!=staging['device']:raise adapter.Refused() + if stat.S_ISDIR(before.st_mode):remove(fd,child,before) + elif stat.S_ISREG(before.st_mode) and before.st_nlink==1:os.unlink(child,dir_fd=fd) + else:raise adapter.Refused() # symlinks/special files/hardlinks are uncertain, never followed + finally:os.close(fd) + after=os.stat(name,dir_fd=parent,follow_symlinks=False) + if (after.st_dev,after.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused() + os.rmdir(name,dir_fd=parent) + remove(parentfd,work.name,info) + try:os.stat(work.name,dir_fd=parentfd,follow_symlinks=False) + except FileNotFoundError: + row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return + raise adapter.Refused() + finally:os.close(parentfd) + + +def recover_obligations(session, staging, receipt): + failed=False + try:cleanup_all(session,receipt) + except Exception:failed=True + # The plaintext obligation is attempted even when Docker is unreachable. + try:cleanup_workdir(staging,receipt) + except Exception:failed=True + if failed:raise adapter.Refused() + + +def process_facts(pid): + raw=Path('/proc')/str(pid)/'stat' + try: + fields=raw.read_text().rsplit(')',1)[1].split() + return {'pid':pid,'state':fields[0],'pgid':int(fields[2]),'sid':int(fields[3]), + 'start_ticks':int(fields[19]),'uid':raw.parent.stat().st_uid} + except FileNotFoundError:return None + + +def isolate_execution(): + # Called by main before reading TLS/key inputs. Never reuse an inherited + # shell, custody-helper, user or unrelated process group. + os.setsid() + pid=os.getpid();facts=process_facts(pid) + if os.getsid(0)!=pid or os.getpgrp()!=pid or facts['sid']!=pid or facts['pgid']!=pid:raise adapter.Refused() + return {k:facts[k] for k in ('pid','pgid','sid','start_ticks','uid')}|{'deadline':time.monotonic()+1800} + + +def verify_execution(execution): + if execution['pid']!=execution['sid'] or execution['pid']!=execution['pgid'] or execution['uid']!=os.getuid():raise adapter.Refused() + current=process_facts(execution['pid']) + if current is not None and any(current[k]!=execution[k] for k in ('pid','pgid','sid','start_ticks','uid')): + # Numeric leader PID reuse is not authority to signal a new group. + raise adapter.Refused() + + +def group_members(execution): + verify_execution(execution) + members=[] + for path in Path('/proc').iterdir(): + if not path.name.isdecimal():continue + facts=process_facts(int(path.name)) + if facts is None or facts['pgid']!=execution['pgid']:continue + if facts['sid']!=execution['sid'] or facts['uid']!=execution['uid'] or facts['start_ticks']bundle.LIMIT+4*1024*1024:raise adapter.Refused() + root=work/'restored';root.mkdir(mode=0o700) + manifest,_=bundle.restore(archive,root) + payload=adapter.private_payload(root,manifest,admission['manifest_sha256']) + import yaml + cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'])) + if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused() + # Public executable readback uses the daemon archive API only on the + # original image rootfs, never a tmpfs or private user-created mount. + probe=session['public_image_probe'] + try: + run(['docker','create','--pull=never','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE]) + binary=adapter.container_file(probe,'/keycape') + if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused() + finally: + receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}] + run(['docker','rm',probe]) + if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused() + receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified' + provision(session,payload,tls,admission['issuer']) + receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files'])) + checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'} + # Native checker consumes no service secret argument or new grant. + # Retry only bounded public readiness; no account/factor attempt. + for attempt in range(40): + try: + numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers() + adapter.check_jwks(session,checked_admission,numbers);break + except Exception: + if attempt==39:raise + time.sleep(.25) + adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False) + finally: + os.close(keyfd) + try: + if session is not None and staging is not None: + uncertain=daemon_uncertain(staging) + receipt['in_flight_daemon_request_uncertain']=uncertain + recover_obligations(session,staging,receipt) + if uncertain:raise adapter.Refused() + if reaper is not None: + reaper.terminate();reaper.wait(timeout=10) + except Exception: + receipt['status']='failed' + raise + finally: + adapter.EXECUTION=None + if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True + + +def persist_receipt(fd,receipt): + raw=(json.dumps(receipt,indent=2)+'\n').encode() + os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0) + with os.fdopen(os.dup(fd),'wb') as output: + output.write(raw);output.flush();os.fsync(output.fileno()) + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True) + args=p.parse_args();os.umask(0o077) + fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False} + try: + execution=isolate_execution() + raw=Path(args.admission).read_bytes() + if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused() + admission=json.loads(raw) + tls={} + for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)): + source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes() + execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,execution,checkpoint=lambda r:persist_receipt(fd,r)) + except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed') + finally: + receipt['finished_at']=datetime.now(timezone.utc).isoformat() + try:persist_receipt(fd,receipt) + finally:os.close(fd) + print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.') + return int(receipt['status']!='isolated_keycape_native_registration_checked') + +if __name__=='__main__': + for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-keycape.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-keycape.py new file mode 100644 index 00000000..32f46439 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-keycape.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""T18 native adapter for an owner-admitted, already restored isolated session. + +Called after identity-bundle.restore, never with an unvalidated directory. This +module does not provision TLS, grant credentials, or claim downstream acceptance. +The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. +""" +import base64 +import hashlib +import importlib.util +import json +import io +import os +import secrets +import tarfile +import time +import re +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' +LABEL = 'net-kingdom.recovery-run' + + +class Refused(Exception): + pass + + +def load_bundle(): + spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def private_payload(root, manifest, expected_manifest_sha256): + """Bind the complete validated archive before selecting KeyCape material. + + Private hashes never enter the public receipt. The digest is supplied by the + existing private archive admission, not inferred from an untrusted manifest. + """ + encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() + if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: + raise Refused() + entries = manifest['files'] + if len(entries) != len({e['path'] for e in entries}): + raise Refused() + values = {} + bundle = load_bundle() + for entry in entries: + if not bundle.allowed(entry['path'], manifest['profile']): + raise Refused() + path = root / entry['path'] + bundle.private(path) + value = path.read_bytes() + if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: + raise Refused() + values[entry['path']] = value + deployment = json.loads(values['resources/sso/deployment/keycape.json']) + main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') + if main.get('command') or main.get('args') or main.get('envFrom'): + raise Refused() + if main['image'] != IMAGE: + # Mutable-tag/historical mismatches need a separate explicit owner admission. + raise Refused() + secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] + files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) + for name in ('config.yaml', 'key.pem')} + for path, value in values.items(): + if path.startswith('data/keycape/'): + relative = path.removeprefix('data/keycape/') + files['/var/lib/keycape-policy/' + relative] = value + if '/var/lib/keycape-policy/policy.json' not in files: + raise Refused() + if 'resources/sso/secret/keycape-factor-read.json' in values: + factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data'] + files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True) + # Resolve original delivered environment, including all referenced secrets. + env = {} + for item in main.get('env', []): + if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused() + if item['name'] in env: + raise Refused() + if 'value' in item: + env[item['name']] = item['value'] + else: + ref = item['valueFrom']['secretKeyRef'] + source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] + env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() + if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': + raise Refused() + return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} + + +ENDPOINT = 'unix:///var/run/docker.sock' +EXECUTION = None + + +def docker_environment(environment=None): + # Even an admitted daemon exposed by another Unix socket is outside this + # exact session binding. Conflicting ambient routing is refused, not ignored. + for source in (os.environ, environment or {}): + if source.get('DOCKER_HOST') not in (None,'',ENDPOINT):raise Refused() + if source.get('DOCKER_CONTEXT') not in (None,'','default'):raise Refused() + if any(source.get(k) for k in ('DOCKER_TLS_VERIFY','DOCKER_CERT_PATH')):raise Refused() + env=dict(os.environ if environment is None else environment) + for key in ('DOCKER_CONTEXT','DOCKER_TLS_VERIFY','DOCKER_CERT_PATH'):env.pop(key,None) + env['DOCKER_HOST']=ENDPOINT + return env + + +def process(argv, **kwargs): + marker=None + if EXECUTION is not None and (kwargs.get('start_new_session') or kwargs.get('process_group') not in (None,-1)):raise Refused() + if argv[0]=='docker': + argv=['docker','--host',ENDPOINT,*argv[1:]] + kwargs['env']=docker_environment(kwargs.get('env')) + if EXECUTION is not None: + if (os.getpid()!=EXECUTION['pid'] or os.getsid(0)!=EXECUTION['sid'] or os.getpgrp()!=EXECUTION['pgid'] + or time.monotonic()>=EXECUTION['deadline']):raise Refused() + marker=Path(EXECUTION['staging']+'-docker-inflight') + fd=os.open(marker,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + os.close(fd) + try: + result=subprocess.run(argv,**kwargs) + if marker is not None and result.returncode!=0: + fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd) + return result + except BaseException: + # Parent loss leaves .docker-inflight; caught timeout/transport failures + # retain a sticky uncertainty marker even if later cleanup can run. + if marker is not None: + fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd) + raise + finally: + if marker is not None: + try:marker.unlink() + except FileNotFoundError:pass + + +def command(argv, **kwargs): + maximum = kwargs.pop('max_output', 32768) + result = process(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=kwargs.pop('timeout', 180), **kwargs) + if result.returncode or len(result.stdout) > maximum: + raise Refused() + return result.stdout + + +def inspect_session(session, worker_only=False): + """No credential environment is read by Docker inspect or returned to caller.""" + if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): + raise Refused() + names = session['containers'] + if not names or len(names) > 12 or len(set(names)) != len(names): + raise Refused() + for name in names: + if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): + raise Refused() + if session['worker'] not in names or session['issuer'] not in names: + raise Refused() + import os + contexts=json.loads(command(['docker','context','inspect'])) + endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST') or ENDPOINT] + if not all(x==ENDPOINT for x in endpoints):raise Refused() + info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) + if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: + raise Refused() + # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. + anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() + for name,spec in session.get('volumes',{}).items(): + volume=json.loads(command(['docker','volume','inspect',name])) + expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} + if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused() + for name in names: + obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}'])) + host = obj['host'] + mounts=obj['mounts'] + destinations=[m['Destination'] for m in mounts] + if len(destinations)!=len(set(destinations)):raise Refused() + for volume,expected_spec in session.get('volumes',{}).items(): + matches=[m for m in mounts if m['Destination']==expected_spec['target']] + if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker'] or expected_spec['target']=='/var/lib/keycape-policy'):raise Refused() + if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') + or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') + or host.get('PidMode') or host.get('IpcMode') == 'host' + or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' + or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {}) + or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') + or host.get('CapDrop') != ['ALL'] + or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) + or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts']) + or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) + or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): + raise Refused() + if worker_only:return + if session['images'][session['issuer']] != IMAGE: + raise Refused() + # Known admitted archive has no command/args override; image default /keycape + # must be the actual issuer executable, not an alternate same-image command. + launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format', + '{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}'])) + if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}: + raise Refused() + + +def container_file(container, path): + # Docker streams one tar member into memory; never materialize private files. + raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024) + with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: + members = archive.getmembers() + if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024: + raise Refused() + return archive.extractfile(members[0]).read() + + +JWKS_PROBE = r""" +import base64,ipaddress,json,socket,ssl,sys,urllib.request +from urllib.parse import urlsplit +p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) +def require(condition): + if not condition:raise ValueError('public_jwks_binding_refused') +require(origin.scheme=='https') +resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM) +require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved)) +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):return None +opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), + urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) +def get(url): + u=urlsplit(url) + require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443)) + require(not u.username and not u.password and not u.fragment) + with opener.open(url,timeout=10) as response: + raw=response.read(1024*1024+1);require(len(raw)<=1024*1024) + return json.loads(raw) +d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer) +keys=get(d['jwks_uri'])['keys'] +def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') +# The exact admitted owner release constructs one key, kid key-1. Reject every +# extra key, including irrelevant keys: a native successful exchange can then +# only have used the original signer through the existing verifier. +require(isinstance(keys,list) and len(keys)==1) +k=keys[0] +require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256') +require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e'])) +print('{"original_public_signing_key_in_live_jwks":true}') +""" + +# A failed public readiness probe is closed data, not a failing Docker RPC. +# Transport/CLI nonzero remains uncertain; no deliberately failing exec is +# retried as if daemon acceptance had been established. +JWKS_TRANSPORT = ("import contextlib,io\ntry:\n" + " with contextlib.redirect_stdout(io.StringIO()):\n" + " exec(" + repr(JWKS_PROBE) + ")\n" + "except Exception:\n print('{\"original_public_signing_key_in_live_jwks\":false}')\n" + "else:\n print('{\"original_public_signing_key_in_live_jwks\":true}')\n") + + +def check_jwks(session, admission, public_numbers): + trust=runtime_file(session,admission['trust_path']).decode() + request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} + result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_TRANSPORT],input=json.dumps(request).encode())) + if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() + + +RUNTIME_FILES = r""" +import base64,json,sys +from pathlib import Path +p=json.load(sys.stdin) +roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') +result={} +paths=[] +if p.get('tree'): + paths=list(Path('/var/lib/keycape-policy').rglob('*')) +else:paths=[Path(x) for x in p['paths']] +for f in paths: + if f.is_dir():continue + if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError() + result[str(f)]=base64.b64encode(f.read_bytes()).decode() + if len(result)>4096:raise ValueError() +print(json.dumps(result)) +""" + + +def runtime_files(session, paths=(), tree=False): + raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES], + input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024) + return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()} + + +def runtime_file(session,path): + return runtime_files(session,[path])[path] + + +def run_verifier(session, admission, payload): + issuer = session['issuer'] + if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() + from urllib.parse import urlsplit + import ipaddress + address = urlsplit(admission['issuer']) + if (address.scheme != 'https' or not address.hostname or address.username or address.password + or address.query or address.fragment): + raise Refused() + if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: + raise Refused() + if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']: + raise Refused() + # Capture private environment only in owner process memory, never receipt. + environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) + env = dict(item.split('=',1) for item in environment) + if len(env)!=len(environment):raise Refused() + if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused() + if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): + raise Refused() + if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused() + for name,value in payload['env'].items(): + if env.get(name) != value: + raise Refused() + files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} + def equal(): + if runtime_files(session,files)!=files:raise Refused() + durable=runtime_files(session,tree=True) + if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: + raise Refused() + equal() + # Reading YAML is data parsing only; no archived executable is evaluated. + import yaml + cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) + if cfg['issuer'] != admission['issuer']: + raise Refused() + consumer = admission['consumer'] + client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) + if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' + or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): + raise Refused() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + wrong = secrets.token_urlsafe(48) + if wrong == env[consumer['secret_env']]:raise Refused() + args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', + '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] + flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', + 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} + for name,flag in flags.items():args+=['-'+flag,consumer[name]] + # Native command reports on stderr. Do not return or print its raw response. + result = process(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) + raw = (result.stdout+result.stderr).decode() + required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', + 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', + 'granted scope carries no excess grant','predecessor secret refused'] + required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] + if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: + raise Refused() + equal() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) + return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, + 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, + 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), + 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} + + +def cleanup(session, receipt=None): + """Attempt every owned cleanup even when one obligation fails. + + Preserve exact safe container names and outcomes so the owner can recover an + uncertain/partial cleanup. An inspect/daemon failure never means absence. + """ + obligations = [] + failed = False + for name in reversed(session['containers']): + row = {'container': name, 'status': 'unverified'} + obligations.append(row) + try: + present=command(['docker','container','ls','--all','--filter','name=^/'+name+'$','--format','{{.Names}}']).decode().splitlines() + if not present: + row['status']='absence_verified' + continue + label = command(['docker', 'container', 'inspect', name, '--format', + '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() + if label != session['run_id']: + row['status'] = 'ownership_mismatch_not_removed' + failed = True + continue + # A failed remove is followed by a successful explicit inventory; + # interrupted remove may actually have completed. + try: + command(['docker', 'rm', '--force', '--volumes', name]) + except Exception: + pass + remaining = command(['docker','container','ls','--all','--filter', + 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() + if remaining: + row['status'] = 'still_present' + failed = True + else: + row['status'] = 'absence_verified' + except Exception: + failed = True + if receipt is not None: + receipt['container_cleanup_obligations'] = obligations + if failed: + raise Refused() + + + +def native_checks(root, manifest, admission, receipt, cleanup_session=True): + """Owner wrapper provides a reviewed session after existing restore pipeline. + + Admission must bind ciphertext, canonical private manifest digest, executable + and trust bytes, exact image/target list, original issuer and selected consumer. + None of these admission fields grant authority or carry a client secret. + """ + session = admission['session'] + # Validate ownership/containment before permitting either exec or cleanup. + inspect_session(session) + receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, + human_mfa_proven=False, downstream_consumer_reconnect_proven=False, + container_cleanup_verified=False) + try: + for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): + if not re.fullmatch('[a-f0-9]{64}', admission[field]): + raise Refused() + for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): + if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): + raise Refused() + payload = private_payload(root, manifest, admission['manifest_sha256']) + result = run_verifier(session, admission, payload) + required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', + 'denied_scopes_refused','original_config_registry_policy_receipts_equal', + 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} + if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): + raise Refused() + # Closed receipt allowlist: never copy arbitrary worker output. + receipt.update({k: True for k in required}) + receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] + receipt['status'] = 'isolated_keycape_native_registration_checked' + finally: + try: + if cleanup_session: + cleanup(session, receipt) + receipt['container_cleanup_verified'] = True + except Exception: + receipt['status'] = 'failed' + raise diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-lldap.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-lldap.py new file mode 100644 index 00000000..abe0865c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-lldap.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Owner-run native LLDAP restore from the verified identity bundle. + +Private inputs remain in the owner process/pipe. Decrypted staging uses Linux +tmpfs. Containers have no network, ports, host mounts or production API access. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import signal +import sqlite3 +import subprocess +import tarfile +import tempfile +import time +import tomllib +import uuid + +HERE = Path(__file__).resolve().parent + + +def load(name): + spec = importlib.util.spec_from_file_location(name, HERE / (name + '.py')) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +verify = load('verify-identity') +offline = load('retrieve-identity-offline') +bundle = verify.bundle +IMAGE = 'docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d' +CLIENT = 'ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473' +ENGINE_ID = '74f93783-6cef-43a8-a3c4-f62586cd3bc1' +ENGINE_NAME = 'bnt-lap001' + + +def command(args, **kwargs): + result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=kwargs.pop('timeout', 90), **kwargs) + if result.returncode: + raise bundle.Refused() + return result.stdout + + +def preflight(): + contexts = json.loads(command(['docker', 'context', 'inspect'])) + endpoints = [contexts[0]['Endpoints']['docker']['Host']] + if os.environ.get('DOCKER_HOST'): + endpoints.append(os.environ['DOCKER_HOST']) + if not all(url.startswith('unix://') for url in endpoints): + raise bundle.Refused() + facts = json.loads(command(['docker', 'info', '--format', + '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) + if facts['ID'] != ENGINE_ID or facts['Name'] != ENGINE_NAME or facts['OSType'] != 'linux': + raise bundle.Refused() + # /dev/shm must actually be tmpfs, not a similarly named disk directory. + mounts = Path('/proc/mounts').read_text().splitlines() + if not any(line.split()[1:3] == ['/dev/shm', 'tmpfs'] for line in mounts): + raise bundle.Refused() + images = [] + for image in (IMAGE, CLIENT): + image_id = command(['docker', 'image', 'inspect', '--format', '{{.Id}}', image]).decode().strip() + images.append({'reference': image, 'image_id': image_id}) + return {'engine_id': facts['ID'], 'engine_name': facts['Name'], 'images': images, + 'production_contacted': False, 'network_mode': 'none', 'host_ports_published': False} + + +def snapshot(path): + # Include all fields of durable identity tables, including stored password + # hashes. Only counts and the equality boolean leave this process. + result = {} + with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db: + if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: + raise bundle.Refused() + tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} + for name in ('users', 'groups', 'memberships', 'user_attributes', 'group_attributes', + 'user_attribute_values', 'group_attribute_values', + 'user_attribute_schema', 'group_attribute_schema'): + if name in tables: + rows = db.execute('SELECT * FROM "' + name + '"').fetchall() + encoded = sorted(repr(row) for row in rows) + result[name] = {'count': len(rows), 'digest': hashlib.sha256('\n'.join(encoded).encode()).digest()} + if not {'users', 'groups', 'memberships'} <= result.keys(): + raise bundle.Refused() + return result + + +def configuration(root): + data = root / 'data/lldap' + existing = data / 'lldap_config.toml' + config = tomllib.loads(existing.read_text()) if existing.is_file() else {} + deployment = bundle.resource(root, 'sso', 'deployment', 'lldap') + main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'lldap') + env = {e['name']: e['value'] for e in main.get('env', []) if 'value' in e} + secret = bundle.resource(root, 'sso', 'secret', 'lldap-secrets')['data'] + jwt = base64.b64decode(secret['LLDAP_JWT_SECRET'], validate=True) + password = base64.b64decode(secret['LLDAP_LDAP_USER_PASS'], validate=True) + if not jwt or not password or b'\n' in jwt or b'\n' in password: + raise bundle.Refused() + base = env.get('LLDAP_LDAP_BASE_DN', config.get('ldap_base_dn')) + user = env.get('LLDAP_LDAP_USER_DN', config.get('ldap_user_dn', 'admin')) + if not isinstance(base, str) or not base or not isinstance(user, str): + raise bundle.Refused() + chosen = {'database_url': 'sqlite:///data/users.db?mode=rwc', + 'ldap_base_dn': base, 'ldap_user_dn': user, + 'ldap_host': '127.0.0.1', 'http_host': '127.0.0.1', + 'ldap_port': 3890, 'http_port': 17170, + 'force_ldap_user_pass_reset': False, 'force_update_private_key': False} + seed = env.get('LLDAP_KEY_SEED', config.get('key_seed')) + if seed: + if not isinstance(seed, str): + raise bundle.Refused() + chosen['key_seed'] = seed + else: + value = env.get('LLDAP_KEY_FILE', config.get('key_file', '/data/private_key')) + key = PurePosixPath(value) + if not key.is_absolute(): + key = PurePosixPath('/data') / key + if key.parent != PurePosixPath('/data') or not (data / key.name).is_file(): + raise bundle.Refused() + chosen['key_file'] = str(key) + # JSON strings are valid TOML basic strings for these service inputs. + raw = '\n'.join(k + ' = ' + json.dumps(v) for k, v in chosen.items()) + raw += '\n[smtp_options]\nenable_password_reset = false\n[ldaps_options]\nenabled = false\n' + return raw.encode(), jwt, password, base + + +def archive_files(files): + out = io.BytesIO() + with tarfile.open(fileobj=out, mode='w') as archive: + for name, value in files.items(): + if PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts: + raise bundle.Refused() + entry = tarfile.TarInfo(name) + entry.size = len(value) + entry.mode = 0o600 + entry.uid = entry.gid = 1000 + archive.addfile(entry, io.BytesIO(value)) + return out.getvalue() + + +PROBE = r''' +import contextlib,io,json,logging,socket,sys,time +logging.disable(logging.CRITICAL) +result={'status':'failed'} +stage='network_isolation' +try: + with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + from pathlib import Path + assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'} + assert len(Path('/proc/net/route').read_text().splitlines())==1 + stage='native_client_import' + from ldap3 import Server,Connection,NONE + from ldap3.utils.dn import escape_rdn + values=json.load(sys.stdin) + stage='directory_startup' + for _ in range(60): + try: + with socket.create_connection(('127.0.0.1',3890),timeout=1):pass + break + except OSError:time.sleep(.25) + else:raise RuntimeError() + dn='uid='+escape_rdn(values['user'])+',ou=people,'+values['base'] + server=Server('127.0.0.1',port=3890,get_info=NONE,connect_timeout=3) + stage='existing_user_bind' + connection=Connection(server,user=dn,password=values['password'],auto_bind=True,raise_exceptions=True) + assert connection.bound + connection.unbind() + stage='wrong_password_bind' + wrong=Connection(server,user=dn,password=values['wrong_password'],auto_bind=False,raise_exceptions=False) + refused=not wrong.bind() and wrong.result.get('result')==49 + wrong.unbind() + assert refused + result={'status':'verified','existing_user_native_ldap_bind':True,'wrong_password_refused':True, + 'only_loopback_interface_verified':True,'external_routes_absent':True} +except Exception as error:result={'status':'failed','failed_stage':stage,'failure_type':type(error).__name__} +print(json.dumps(result)) +''' + + +def restore_lldap(root, user, password, receipt): + before = snapshot(root / 'data/lldap/users.db') + config, jwt, admin_password, base = configuration(root) + name = 'nk49-lldap-' + uuid.uuid4().hex + client_name = name + '-client' + try: + command(['docker', 'run', '--detach', '--pull=never', '--name', name, + '--network', 'none', '--read-only', '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', '--user', '1000:1000', + '--memory', '256m', '--cpus', '1', '--pids-limit', '128', + '--tmpfs', '/data:rw,nosuid,nodev,noexec,size=268435456,uid=1000,gid=1000,mode=0700', + '--tmpfs', '/run/recovery:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700', + '--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000', + '--env', 'LLDAP_JWT_SECRET_FILE=/run/recovery/jwt', + '--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/recovery/admin-password', + '--entrypoint', '/bin/sh', IMAGE, '-c', 'sleep 1800']) + guard = json.loads(command(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}'])) + host = guard['host'] + if (host['NetworkMode'] != 'none' or not host['ReadonlyRootfs'] or host['Privileged'] + or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', []) + or 'no-new-privileges' not in host.get('SecurityOpt', []) + or guard['user'] != '1000:1000' + or any(mount['Type'] != 'tmpfs' for mount in guard['mounts'])): + raise bundle.Refused() + receipt['container_isolation_verified_before_private_import'] = True + payload = {p.relative_to(root / 'data/lldap').as_posix(): p.read_bytes() + for p in (root / 'data/lldap').rglob('*') if p.is_file()} + command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/data'], input=archive_files(payload)) + command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/recovery'], + input=archive_files({'lldap.toml': config, 'jwt': jwt, 'admin-password': admin_password})) + # No credential values in Docker configuration, environment or argv. + command(['docker', 'exec', '--detach', name, '/bin/sh', '-c', + 'echo $$ > /run/recovery/server.pid; exec /app/lldap run --config-file /run/recovery/lldap.toml > /dev/null 2>&1']) + raw = command(['docker', 'run', '--interactive', '--rm', '--name', client_name, '--pull=never', '--network', 'container:' + name, + '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--memory', '256m', '--cpus', '1', '--pids-limit', '64', + '--entrypoint', 'python3', CLIENT, '-c', PROBE], + input=json.dumps({'user': user, 'password': password, 'base': base, + 'wrong_password': 'nk49-wrong-' + uuid.uuid4().hex}).encode()) + result = json.loads(raw) + receipt.update(result) + if result.get('status') != 'verified': + raise bundle.Refused() + command(['docker', 'exec', name, '/bin/sh', '-c', 'kill -TERM "$(cat /run/recovery/server.pid)"']) + for _ in range(60): + running = subprocess.run(['docker', 'exec', name, '/bin/sh', '-c', + 'kill -0 "$(cat /run/recovery/server.pid)"'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + if running.returncode: + break + time.sleep(.1) + else: + raise bundle.Refused() + raw = command(['docker', 'exec', name, '/bin/sh', '-c', + 'test ! -s /data/users.db-wal && cat /data/users.db']) + after_path = root / 'post-restore-users.db' + after_path.write_bytes(raw) + if snapshot(after_path) != before: + raise bundle.Refused() + receipt.update(directory_identity_rows_preserved=True, + directory_comparison_scope=sorted(before), + directory_table_counts={k: v['count'] for k, v in before.items()}) + finally: + # A failed create can still have created a container (e.g. lost reply). + for owned in (client_name, name): + found = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=15) + if found.returncode == 0: + command(['docker', 'rm', '--force', '--volumes', owned]) + absent = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=15) + command(['docker', 'info', '--format', '{{.ID}}']) + if absent.returncode == 0: + raise bundle.Refused() + receipt['container_cleanup_verified'] = True + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--transfer-receipt', required=True) + parser.add_argument('--receipt', required=True) + parser.add_argument('--user', required=True) + args = parser.parse_args() + os.umask(0o077) + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + result = {'schema': 'netkingdom.isolated-lldap-recovery.v1', 'status': 'failed', + 'production_contacted': False, 'secret_values_exported': False, + 'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False, + 'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} + keyfd = None + work = None + stage = 'target_preflight' + try: + result['target'] = preflight() + stage = 'input_validation' + if not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', args.user): + raise bundle.Refused() + transfer = json.loads(Path(args.transfer_receipt).read_text()) + offline.validate(transfer) + if transfer.get('independent_owner_retrieval_verified') is not True: + raise bundle.Refused() + path = Path(transfer['fetched_path']) + bundle.private(path, max_bytes=bundle.LIMIT) + if path.stat().st_size != transfer['ciphertext_bytes'] or offline.offsite.digest(path) != transfer['ciphertext_sha256']: + raise bundle.Refused() + keyfd = verify.custody.private_key_fd() + derived = command(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip() + if derived != bundle.profile()['public_recipient'].encode(): + raise bundle.Refused() + stage = 'private_existing_password_prompt' + password = offline.private_prompt('Existing account password for the isolated LLDAP check: ') + with tempfile.TemporaryDirectory(prefix='nk49-service-recovery-', dir='/dev/shm') as temporary: + work = Path(temporary) + archive = work / 'archive.tar' + stage = 'archive_decryption' + with archive.open('xb') as output: + proc = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)], + stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180) + if proc.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024: + raise bundle.Refused() + restored = work / 'restored' + restored.mkdir(mode=0o700) + stage = 'archive_validation' + manifest, _ = bundle.restore(archive, restored) + result.update(ciphertext_sha256=transfer['ciphertext_sha256'], + manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True) + stage = 'native_directory_check' + restore_lldap(restored, args.user, password, result) + password = None + if work.exists(): + raise bundle.Refused() + result.update(status='isolated_lldap_verified', temporary_plaintext_cleanup_verified=True) + except (Exception, KeyboardInterrupt): + result.update(status='failed', error='isolated_lldap_check_failed_private_diagnostics_suppressed') + result.setdefault('failed_stage', stage) + finally: + if keyfd is not None: + os.close(keyfd) + if work is not None and not work.exists(): + result['temporary_plaintext_cleanup_verified'] = True + result['finished_at'] = datetime.now(timezone.utc).isoformat() + with os.fdopen(fd, 'w') as output: + json.dump(result, output, indent=2) + output.write('\n') + success = result['status'] == 'isolated_lldap_verified' + print('OK: restored LLDAP existing-user bind, wrong-password refusal, directory preservation and cleanup passed.' + if success else 'ERROR: isolated LLDAP recovery refused; review the safe receipt. No production change.') + return 0 if success else 1 + + +if __name__ == '__main__': + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, offline.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-privacyidea.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-privacyidea.py new file mode 100644 index 00000000..a0862290 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/isolated-privacyidea.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Private native privacyIDEA token-material and audit recovery checks. + +This does not authenticate a user's MFA code. It restores only a disposable +database, decrypts token material internally and verifies stored audit rows. +""" +import argparse +import ast +import base64 +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time +import uuid + +# Import the already reviewed local target/transport boundary without changing +# the accepted LLDAP executor or its source digest. +import importlib.util +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('isolated_lldap', HERE / 'isolated-lldap.py') +local = importlib.util.module_from_spec(spec) +spec.loader.exec_module(local) +bundle = local.bundle +IMAGE = local.CLIENT +POSTGRES = local.verify.pg.IMAGE + + +def run(args, **kwargs): + return local.command(args, **kwargs) + + +def preflight(): + target = local.preflight() + target['images'].append({'reference': POSTGRES, + 'image_id': run(['docker', 'image', 'inspect', '--format', '{{.Id}}', POSTGRES]).decode().strip()}) + return target + + +def isolation(name, expected_network, user): + info = json.loads(run(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}'])) + host = info['host'] + if (host['NetworkMode'] != expected_network or not host['ReadonlyRootfs'] or host['Privileged'] + or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', []) + or 'no-new-privileges' not in host.get('SecurityOpt', []) or info['user'] != user + or any(m['Type'] != 'tmpfs' for m in info['mounts'])): + raise bundle.Refused() + + +def cleanup(names): + for name in names: + present = subprocess.run(['docker', 'container', 'inspect', name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15) + if present.returncode == 0: + run(['docker', 'rm', '--force', '--volumes', name]) + absent = subprocess.run(['docker', 'container', 'inspect', name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15) + run(['docker', 'info', '--format', '{{.ID}}']) + if absent.returncode == 0: + raise bundle.Refused() + + +def start_database(name): + run(['docker', 'run', '--detach', '--pull=never', '--name', name, '--network', 'none', + '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--user', '999:999', '--memory', '2g', '--cpus', '2', '--pids-limit', '128', + '--tmpfs', '/var/lib/postgresql/data:rw,nosuid,nodev,noexec,size=1073741824,uid=999,gid=999,mode=0700', + '--tmpfs', '/var/run/postgresql:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999', + '--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999', + '--env', 'POSTGRES_HOST_AUTH_METHOD=trust', '--env', 'POSTGRES_DB=recovery', + '--entrypoint', '/usr/bin/timeout', POSTGRES, '--kill-after=15s', '1800s', + '/usr/local/bin/docker-entrypoint.sh', 'postgres', '-c', 'listen_addresses=127.0.0.1']) + isolation(name, 'none', '999:999') + for _ in range(80): + # The entrypoint's temporary init server has TCP disabled. Require TCP + # readiness so we cannot mistake it for the final local-only server. + ready = subprocess.run(['docker', 'exec', name, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres', '-d', 'recovery'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + if ready.returncode == 0: + return + time.sleep(.25) + raise bundle.Refused() + + +def start_worker(name, database, root): + secret = bundle.resource(root, 'mfa', 'secret', 'privacyidea-config')['data'] + config = {'SQLALCHEMY_DATABASE_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery', + 'PI_AUDIT_SQL_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery', + 'SECRET_KEY': base64.b64decode(secret['PI_SECRET_KEY'], validate=True).decode(), + 'PI_PEPPER': base64.b64decode(secret['PI_PEPPER'], validate=True).decode(), + 'PI_ENCFILE': '/recovery/enckey', 'PI_AUDIT_KEY_PRIVATE': '/recovery/private.pem', + 'PI_AUDIT_KEY_PUBLIC': '/recovery/public.pem', 'PI_AUDIT_NO_SIGN': False, + 'PI_CHECK_OLD_SIGNATURES': legacy_setting(root), 'PI_NO_RESPONSE_SIGN': True, + 'PI_LOGFILE': '/recovery/runtime.log', 'PI_LOGLEVEL': 50, + 'PI_UUID_FILE': '/recovery/node-uuid', + 'PI_TRUSTED_JWT': []} + # This deliberately does not execute archived pi.cfg or reuse its live DB URI. + payload = {'pi.cfg': '\n'.join(k + '=' + repr(v) for k, v in config.items()).encode()} + for key in ('enckey', 'private.pem', 'public.pem'): + payload[key] = (root / 'data/privacyidea' / key).read_bytes() + run(['docker', 'run', '--detach', '--pull=never', '--name', name, + '--network', 'container:' + database, '--read-only', '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', '--user', '1000:1000', + '--memory', '1g', '--cpus', '2', '--pids-limit', '128', + '--tmpfs', '/recovery:rw,nosuid,nodev,noexec,size=33554432,uid=1000,gid=1000,mode=0700', + '--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000', + '--tmpfs', '/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700', + '--env', 'PRIVACYIDEA_CONFIGFILE=/recovery/pi.cfg', + '--entrypoint', 'python3', IMAGE, '-c', 'import time;time.sleep(1800)']) + # Docker normalizes container: to container: in HostConfig. + database_id = run(['docker', 'container', 'inspect', database, '--format', '{{.Id}}']).decode().strip() + isolation(name, 'container:' + database_id, '1000:1000') + run(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/recovery'], + input=local.archive_files(payload)) + + +def legacy_setting(root): + path = root / 'resources/mfa/configmap/privacyidea-cfg.json' + if not path.exists(): + return False # Only the lower-level synthetic fixture omits ConfigMaps. + raw = bundle.resource(root, 'mfa', 'configmap', 'privacyidea-cfg')['data']['pi.cfg'] + selected = False + for node in ast.parse(raw).body: + if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'PI_CHECK_OLD_SIGNATURES' for t in node.targets): + if not isinstance(node.value, ast.Constant) or type(node.value.value) is not bool: + raise bundle.Refused() + selected = node.value.value + return selected + + +PROBE = r''' +import contextlib,io,json,logging,os,sys +logging.disable(logging.CRITICAL) +os.umask(0o077) +result={'status':'failed'} +stage='network_isolation' +try: + with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + from pathlib import Path + assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'} + assert len(Path('/proc/net/route').read_text().splitlines())==1 + stage='native_application_configuration' + from privacyidea.app import create_app + app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True) + from privacyidea.models import db,Token + from privacyidea.lib.security.default import DefaultSecurityModule + from privacyidea.lib.auditmodules.sqlaudit import Audit,LogEntry + from privacyidea.lib.crypto import Sign + import binascii + with app.app_context(): + assert str(db.engine.url)=='postgresql+psycopg2://postgres@127.0.0.1:5432/recovery' + stage='retained_token_decryption' + security=DefaultSecurityModule({'file':'/recovery/enckey'}) + wrong_path=Path('/recovery/wrong-enckey');wrong_path.write_bytes(os.urandom(96)) + wrong=DefaultSecurityModule({'file':str(wrong_path)}) + total=Token.query.count() + assert 00 + result.update(retained_tokens_total=total,retained_tokens_with_native_decryption=checked, + different_key_cannot_reproduce_material_count=negative, + retained_token_material_decrypted_inside_process=True,existing_token_mfa_proven=False) + stage='historical_audit_verification' + audit=Audit(app.config) + allow_legacy=bool(app.config['PI_CHECK_OLD_SIGNATURES']) + historical=audit.session.query(LogEntry).count() + assert 0 bundle.LIMIT + 4 * 1024 * 1024: + raise bundle.Refused() + restored = work / 'restored';restored.mkdir(mode=0o700) + stage = 'archive_validation' + manifest, _ = bundle.restore(archive, restored) + result.update(ciphertext_sha256=transfer['ciphertext_sha256'], + manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True) + stage = 'native_privacyidea_checks' + native_checks(restored, result) + result['status'] = 'isolated_privacyidea_checked' + except (Exception, KeyboardInterrupt): + result.update(status='failed', error='isolated_privacyidea_check_failed_private_diagnostics_suppressed') + result.setdefault('failed_stage', stage) + finally: + if keyfd is not None:os.close(keyfd) + if work is not None and not work.exists():result['temporary_plaintext_cleanup_verified'] = True + result['finished_at'] = datetime.now(timezone.utc).isoformat() + with os.fdopen(fd, 'w') as output: + json.dump(result, output, indent=2);output.write('\n') + if result['status'] == 'failed': + print('ERROR: isolated privacyIDEA check failed; review safe receipt. No production change.') + return 1 + if not result['audit_signature_continuity_proven']: + print('REVIEW: token-material checks completed; historical audit exceptions require owner review. Cleanup passed; see safe receipt.') + return 2 + print('OK: retained token material decrypted internally; historical/new audit signatures and negative checks passed; cleanup passed. Existing-token MFA remains separate.') + return 0 + + +if __name__ == '__main__': + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):signal.signal(sig, local.offline.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/prepare-identity-coverage.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/prepare-identity-coverage.py new file mode 100644 index 00000000..4d946eff --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/prepare-identity-coverage.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Prepare exact identity backup coverage from read-only workload/PVC metadata. + +This produces a proposal, never reads Secrets, executes in pods, changes cluster +resources, captures private files, uploads, schedules or grants credentials. +""" +import argparse +from datetime import datetime,timezone +import hashlib +import json +import os +from pathlib import Path +import subprocess + +ROOT=Path(__file__).resolve().parents[2] +CLUSTER_UID='a553c742-0115-43d4-99a4-a5ca56fe0786' +PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json' + + +class Refused(Exception):pass + + +def query(namespace,kind,name): + # Closed read-only types; Secret/ConfigMap data and arbitrary API paths have + # no transport in this preparation command. + if kind not in {'namespace','deployment','pvc'}:raise Refused() + args=['kubectl','--request-timeout=20s'] + if namespace:args+=['-n',namespace] + r=subprocess.run(args+['get',kind,name,'-o','json'],capture_output=True,timeout=25) + if r.returncode:raise Refused() + return json.loads(r.stdout) + + +def references(spec): + secrets=set();configmaps=set();pvcs=set() + for c in spec.get('containers',[])+spec.get('initContainers',[]): + for e in c.get('env',[]): + v=e.get('valueFrom',{}) + if 'secretKeyRef' in v:secrets.add(v['secretKeyRef']['name']) + if 'configMapKeyRef' in v:configmaps.add(v['configMapKeyRef']['name']) + for e in c.get('envFrom',[]): + if 'secretRef' in e:secrets.add(e['secretRef']['name']) + if 'configMapRef' in e:configmaps.add(e['configMapRef']['name']) + for v in spec.get('volumes',[]): + if 'secret' in v:secrets.add(v['secret']['secretName']) + if 'configMap' in v:configmaps.add(v['configMap']['name']) + if 'persistentVolumeClaim' in v:pvcs.add(v['persistentVolumeClaim']['claimName']) + for p in v.get('projected',{}).get('sources',[]): + if 'secret' in p:secrets.add(p['secret']['name']) + if 'configMap' in p:configmaps.add(p['configMap']['name']) + return secrets,configmaps,pvcs + + +def prepare(profile,get=query): + if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused() + bindings=[] + for component in profile['components']: + ns=component['namespace'];name=component['deployment'] + d=get(ns,'deployment',name);m=d['metadata'] + if (m['uid']!=component['deployment_uid'] or m.get('deletionTimestamp') or + d['status'].get('observedGeneration')!=m['generation'] or d['status'].get('readyReplicas')!=1):raise Refused() + refs=references(d['spec']['template']['spec']) + expected_pvcs={p['name']:p['uid'] for p in component['pvc_refs']} + if refs[0]!=set(component['secret_refs']) or refs[1]!=set(component['configmap_refs']) or refs[2]!=set(expected_pvcs):raise Refused() + pvcs=[] + for pvc,expected in expected_pvcs.items(): + obj=get(ns,'pvc',pvc) + if obj['metadata']['uid']!=expected or obj['metadata'].get('deletionTimestamp') or obj['status'].get('phase')!='Bound':raise Refused() + pvcs.append({'name':pvc,'uid':expected}) + bindings.append({'namespace':ns,'deployment':name,'uid':m['uid'],'generation':m['generation'], + 'pvc_bindings':pvcs,'secret_names_only':sorted(refs[0]),'configmap_names_only':sorted(refs[1])}) + if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused() + return {'schema':'netkingdom.identity-backup-preparation.v1','status':'prepared_not_admitted', + 'observed_at':datetime.now(timezone.utc).isoformat(),'cluster_uid':CLUSTER_UID, + 'profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(), + 'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'bindings':bindings,'secret_values_read':False,'backup_captured':False,'uploaded':False, + 'runtime_changed':False,'credential_grant_created':False, + 'owner_admission_required':profile['admission_gates']} + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True) + a=p.parse_args() + try: + r=prepare(json.loads(PROFILE.read_text())) + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + except Exception: + print('ERROR: coverage preparation refused; private diagnostics suppressed.');return 1 + print('OK: exact coverage proposal prepared; capture, delivery and owner admission remain pending.');return 0 + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/recovery-kit.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/recovery-kit.py new file mode 100644 index 00000000..3e1db5ba --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/recovery-kit.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Build a secret-free offline recovery kit; optionally publish exact new files. + +Publication creates only three fixed versioned objects beside identity backups, +never changes shares, credentials, existing files, retention or runtime jobs. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import sys +import urllib.error +import urllib.request +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('offline', HERE / 'retrieve-identity-offline.py') +offline = importlib.util.module_from_spec(spec) +spec.loader.exec_module(offline) +offsite = offline.offsite +FILES = [ + 'docs/disaster-recovery-storage-share.md', + 'docs/identity-service-restore-packet.md', + 'docs/evidence/2026-10-16-identity-backup-live/actual-offsite.json', + 'docs/evidence/2026-10-16-identity-outage-recovery/image-observation.json', + 'tools/bootstrap-recovery/retrieve-identity-offline.py', + 'tools/bootstrap-recovery/identity-offsite.py', + 'tools/bootstrap-recovery/verify-identity.py', + 'tools/bootstrap-recovery/identity-bundle.py', + 'tools/bootstrap-recovery/check-privacyidea-database.py', + 'tools/bootstrap-recovery/identity-backup-profile.json', + 'sso-mfa/bootstrap/sops-custody-unlock.sh', + 'sso-mfa/bootstrap/sops-private.py', + 'keys/age.pub', +] + + +def build(): + content = {name: (ROOT / name).read_bytes() for name in FILES} + if any(b'AGE-SECRET-KEY-1' in value for name, value in content.items() + if not name.endswith(('.py', '.sh', '.md'))): + raise offsite.Refused() + sums = ''.join(hashlib.sha256(value).hexdigest() + ' ' + name + '\n' + for name, value in content.items()).encode() + output = io.BytesIO() + with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED) as archive: + for name, value in {**content, 'SHA256SUMS': sums}.items(): + entry = zipfile.ZipInfo(name, date_time=(2026, 10, 16, 0, 0, 0)) + entry.external_attr = 0o100600 << 16 + entry.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(entry, value) + kit = output.getvalue() + version = hashlib.sha256(kit).hexdigest() + stem = 'net-kingdom-recovery-' + version[:16] + return { + stem + '-START-HERE.md': content['docs/disaster-recovery-storage-share.md'], + stem + '.zip': kit, + stem + '.zip.sha256': (version + ' ' + stem + '.zip\n').encode(), + } + + +def publish(files, headers, client=None): + client = client or offsite.opener() + result = [] + for name, raw in files.items(): + url = offline.ACCOUNT_BASE + name + digest = hashlib.sha256(raw).hexdigest() + # A retry may accept an identical object after exact readback, but must + # never overwrite a conflicting object or retry an unknown PUT outcome. + present = False + try: + with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response: + version = offsite.etag(response, url, len(raw)) + present = True + except urllib.error.HTTPError as error: + if error.code != 404: + raise + if not present: + h = dict(headers, **{'If-None-Match': '*', 'Content-Length': str(len(raw)), + 'Content-Type': 'application/zip' if name.endswith('.zip') else 'text/plain; charset=utf-8'}) + with client.open(urllib.request.Request(url, data=raw, method='PUT', headers=h), timeout=60) as response: + if response.status != 201 or response.url != url: + raise offsite.Refused() + with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response: + version = offsite.etag(response, url, len(raw)) + h = dict(headers, **{'If-Match': version}) + with client.open(urllib.request.Request(url, headers=h), timeout=60) as response: + if offsite.etag(response, url, len(raw)) != version: + raise offsite.Refused() + downloaded = response.read(len(raw) + 1) + if downloaded != raw: + raise offsite.Refused() + result.append({'name': name, 'bytes': len(raw), 'sha256': digest, + 'etag': version, 'readback_verified': True}) + return result + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--output-dir', required=True) + p.add_argument('--publish', action='store_true') + p.add_argument('--auth-source', choices=('prompt', 'attended-openbao'), default='prompt') + p.add_argument('--receipt', required=True) + p.add_argument('--offline-transfer-receipt', help='After prompted publication, retrieve the known recovered generation using the same independently held access') + a = p.parse_args() + os.umask(0o077) + fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + r = {'schema': 'netkingdom.recovery-kit.v1', 'status': 'failed', + 'secret_values_exported': False, 'new_grants_created': False} + stage = 'option_validation' + try: + if a.offline_transfer_receipt and (not a.publish or a.auth_source != 'prompt'): + raise offsite.Refused() + stage = 'build_kit' + files = build() + stage = 'write_local_kit' + directory = Path(a.output_dir) + directory.mkdir(mode=0o700, exist_ok=False) + for name, raw in files.items(): + (directory / name).write_bytes(raw) + r.update(status='built', files=[{'name': n, 'bytes': len(v), 'sha256': hashlib.sha256(v).hexdigest()} + for n, v in files.items()]) + if a.publish: + if a.auth_source == 'prompt': + stage = 'private_provider_prompt' + secret = offline.private_prompt('Independently held Storage Share Backup app password: ') + else: + stage = 'attended_owner_custody' + spec = importlib.util.spec_from_file_location('inventory', HERE / 'inventory-storage-share.py') + owner = importlib.util.module_from_spec(spec) + spec.loader.exec_module(owner) + _, credentials = owner.caller_credentials() + secret = credentials['BACKUP_PASSWORD'] + if not secret or any(c in secret for c in '\r\n\x00'): + raise offsite.Refused() + try: + headers = {'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(), + 'Accept-Encoding': 'identity'} + stage = 'provider_publication' + r.update(status='published_readback_verified', files=publish(files, headers), + folder='Backup/railiance-backups/net-kingdom-identity', + provider='Storage Share', credential_source=a.auth_source) + if a.offline_transfer_receipt: + stage = 'offline_provider_download' + transfer_fd = os.open(a.offline_transfer_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + transfer = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'} + try: + record = json.loads((ROOT / FILES[2]).read_text()) + transfer = offline.retrieve(record, secret, 'account') + finally: + with os.fdopen(transfer_fd, 'w') as out: + json.dump(transfer, out, indent=2) + out.write('\n') + r['offline_retrieval_verified'] = transfer['status'] == 'readback_verified' + finally: + secret = None + r['finished_at'] = datetime.now(timezone.utc).isoformat() + except (Exception, KeyboardInterrupt) as error: + r.update(status='failed', error='kit_operation_failed_private_diagnostics_suppressed', + failed_stage=stage, **offline.failure_metadata(error)) + finally: + with os.fdopen(fd, 'w') as output: + json.dump(r, output, indent=2) + output.write('\n') + # Silent: safe to use in the existing captured attended-owner conduit. + return int(r['status'] == 'failed') + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py new file mode 100644 index 00000000..a6173235 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Download one reviewed ciphertext using privately entered provider access. + +No OpenBao, SSO, Kubernetes, credential discovery, provider writes or age key. +Run by the operator in their own terminal, never through a recorded agent PTY. +""" +import argparse +import base64 +from datetime import datetime, timezone +import getpass +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import signal +import sys +import tempfile +import urllib.request +import urllib.error +import warnings + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('offsite', HERE / 'identity-offsite.py') +offsite = importlib.util.module_from_spec(spec) +spec.loader.exec_module(offsite) +ACCOUNT_BASE = offsite.HOST + '/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/' + + +def private_prompt(label): + # getpass opens the terminal with os.open, which does not require seeking. + # Reject redirected input and turn its echoing fallback warning into an + # exception before it can read a credential without echo suppression. + if not sys.stdin.isatty() or not sys.stderr.isatty(): + raise offsite.Refused() + with warnings.catch_warnings(): + warnings.simplefilter('error', getpass.GetPassWarning) + return getpass.getpass(label) + + +def failure_metadata(error): + # No exception messages, response bodies, URLs or headers enter receipts. + if isinstance(error, urllib.error.HTTPError): + return {'failure_kind': 'provider_http_error', 'http_status': int(error.code)} + if isinstance(error, urllib.error.URLError): + return {'failure_kind': 'provider_transport_error'} + if isinstance(error, getpass.GetPassWarning): + return {'failure_kind': 'terminal_echo_suppression_unavailable'} + if isinstance(error, offsite.Refused): + return {'failure_kind': 'scope_or_integrity_refusal'} + return {'failure_kind': 'local_operation_failed'} + + +def validate(record): + if (record.get('schema') != 'netkingdom.identity-offsite.v1' + or record.get('status') != 'readback_verified' + or record.get('provider') != 'Storage Share' + or record.get('folder') != 'Backup/railiance-backups/net-kingdom-identity' + or not offsite.NAME.fullmatch(record.get('artifact', '')) + or not re.fullmatch('[a-f0-9]{64}', record.get('ciphertext_sha256', '')) + or type(record.get('ciphertext_bytes')) is not int + or not 0 < record['ciphertext_bytes'] <= offsite.LIMIT + or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"', record.get('etag', ''))): + raise offsite.Refused() + + +def retrieve(record, secret, mode, client=None): + validate(record) + if mode not in {'account', 'folder-share'} or not secret or any(c in secret for c in '\r\n\x00'): + raise offsite.Refused() + auth = offsite.headers(secret) if mode == 'folder-share' else { + 'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(), + 'Accept-Encoding': 'identity'} + url = (offsite.BASE if mode == 'folder-share' else ACCOUNT_BASE) + record['artifact'] + client = client or offsite.opener() + directory = None + complete = False + try: + with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + directory = Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-', dir='/tmp')) + target = directory / record['artifact'] + auth['If-Match'] = record['etag'] + count = 0 + digest = hashlib.sha256() + with client.open(urllib.request.Request(url, headers=auth), timeout=180) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as output: + while block := response.read(1024 * 1024): + count += len(block) + if count > record['ciphertext_bytes']: + raise offsite.Refused() + digest.update(block) + output.write(block) + output.flush() + os.fsync(output.fileno()) + if count != record['ciphertext_bytes'] or digest.hexdigest() != record['ciphertext_sha256']: + raise offsite.Refused() + with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + complete = True + # Closed allowlist: never copy arbitrary source-receipt fields to output. + result = {k: record[k] for k in ('schema', 'provider', 'folder', 'artifact', + 'ciphertext_bytes', 'ciphertext_sha256', 'etag')} + result.update(status='readback_verified', fetched_path=str(target), + independent_owner_retrieval_verified=True, + credential_source='independently_held_provider_access', + provider_auth_mode=mode, openbao_contacted=False, sso_contacted=False, + provider_writes_performed=False, age_private_key_used=False, + secret_values_exported=False, object_unchanged_during_transfer=True, + downloaded_at=datetime.now(timezone.utc).isoformat()) + return result + finally: + if directory is not None and not complete: + shutil.rmtree(directory) + + +def interrupted(signum, frame): + raise InterruptedError() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source-receipt', required=True) + parser.add_argument('--receipt', required=True) + parser.add_argument('--auth', choices=('account', 'folder-share'), default='account') + args = parser.parse_args() + os.umask(0o077) + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + result = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'} + stage = 'source_receipt_validation' + try: + record = json.loads(Path(args.source_receipt).read_text()) + validate(record) + # getpass must never fall back to echoed stdin (e.g. a captured agent tool). + stage = 'private_provider_prompt' + secret = private_prompt('Storage Share Backup app password or exact folder-share token: ') + try: + stage = 'provider_download' + result = retrieve(record, secret, args.auth) + finally: + secret = None + except (Exception, KeyboardInterrupt) as error: + result.update(failed_stage=stage, **failure_metadata(error)) + finally: + with os.fdopen(fd, 'w') as output: + json.dump(result, output, indent=2) + output.write('\n') + ok = result['status'] == 'readback_verified' + print('OK: ciphertext retrieved without OpenBao or SSO; ETag, size and SHA256 verified.' if ok + else 'ERROR: retrieval refused; private diagnostics suppressed. Use a new receipt path to retry.') + return 0 if ok else 1 + + +if __name__ == '__main__': + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, interrupted) + sys.exit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/retrieve-identity.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/retrieve-identity.py new file mode 100644 index 00000000..ed386d9a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/retrieve-identity.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Silent attended owner retrieval of an existing identity ciphertext. + +Uses Backup-account recovery custody only in memory; no private age key, upload, +share, grant, deletion or production restore operation. +""" +import argparse +import base64 +from datetime import datetime,timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import tempfile +import urllib.request + +HERE=Path(__file__).resolve().parent + + +def load(name): + spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py')) + m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m + + +inventory=load('inventory-storage-share');offsite=load('identity-offsite') + + +def retrieve(record,out): + if (record.get('schema')!='netkingdom.identity-offsite.v1' or record.get('status')!='readback_verified' + or not offsite.NAME.fullmatch(record.get('artifact','')) or not re.fullmatch('[a-f0-9]{64}',record.get('ciphertext_sha256','')) + or not 0record['ciphertext_bytes']:raise offsite.Refused() + digest.update(block);output.write(block) + if count!=record['ciphertext_bytes'] or digest.hexdigest()!=record['ciphertext_sha256']:raise offsite.Refused() + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response: + if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused() + out.update(record);out.update(status='readback_verified',fetched_path=str(target), + independent_owner_retrieval_verified=True,caller_entity_id=identity.get('entity_id'), + age_private_key_used=False,secret_values_exported=False,downloaded_at=datetime.now(timezone.utc).isoformat()) + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--source-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077);fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} + try:retrieve(json.loads(Path(a.source_receipt).read_text()),r) + except Exception:r.update(status='failed',error='contained_owner_retrieval_failed') + finally: + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh new file mode 100644 index 00000000..74bc6e9f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Candidate owner launcher. Review and native fixture proof required before use. +set +x +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd -- "$ROOT" +umask 077 +[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; } +[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; } +for index in 1 2 4 5 6; do + [[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; } +done +[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; } +for tool in python3 age age-keygen docker; do + command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; } +done +python3 - <<'PY' +import importlib.util +from pathlib import Path +p=Path('tools/bootstrap-recovery/isolated-keycape-session.py') +spec=importlib.util.spec_from_file_location('session',p) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +try: + m.local.preflight() + m.run(['docker','image','inspect',m.adapter.IMAGE]) +except Exception: + raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.') +print('OK: fixed local target and cached pinned images available.') +PY +NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)" +printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN" +printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.' +printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.' +printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.' +bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ + --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \ + --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \ + --tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json" +printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN" diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/verify-bundle.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/verify-bundle.py new file mode 100644 index 00000000..e7b3cfb6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r5/test-root/tools/bootstrap-recovery/verify-bundle.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Isolated ciphertext recovery check; no cluster, credentials or values emitted. + +This verifies the handoff transport and exact requested file coverage. Current +service validity, independent source custody and native consumer recovery need +separate owner evidence; this tool cannot infer them from a decrypted archive. +""" +import argparse +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import signal +import subprocess +import sys +import tarfile +import tempfile +from datetime import datetime, timezone + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('private_age_recovery', ROOT / 'sso-mfa/bootstrap/sops-private.py') +custody = importlib.util.module_from_spec(spec); spec.loader.exec_module(custody) +LIMIT = 64 * 1024 * 1024 +ALLOWED = {f'{c}/secrets.env' for c in ('privacyidea', 'postgres', 'lldap', 'authelia', 'keycape', 'breakglass')} | { + 'privacyidea/pi.enc', 'privacyidea/private.pem', 'privacyidea/public.pem', + 'keycape/key.pem', 'authelia/oidc_private_key.pem', 'openbao/init.json'} + + +class Refused(Exception): + pass + + +def recover(ciphertext, keyfd, required, work): + # Exclude ambient age keys/plugins and unrelated credential environments. + env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': str(work), 'XDG_CONFIG_HOME': str(work)} + result = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}'], input=ciphertext, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), + timeout=60, env=env, cwd=work) + if result.returncode or not result.stdout or len(result.stdout) > LIMIT: + raise Refused() + restored = set(); names = set(); total = 0 + with tarfile.open(fileobj=io.BytesIO(result.stdout), mode='r:') as archive: + for index, member in enumerate(archive): + if index >= 128 or member.name in names: + raise Refused() + names.add(member.name) + path = PurePosixPath(member.name) + if (path.is_absolute() or '..' in path.parts or '.' in path.parts + or not path.parts or path.parts[0] != 'secrets'): + raise Refused() + if member.isdir(): + if len(path.parts) > 2 or (len(path.parts) == 2 and path.parts[1] not in {p.split('/')[0] for p in ALLOWED}): + raise Refused() + continue + relative = '/'.join(path.parts[1:]) + if not member.isfile() or relative not in ALLOWED or not 0 <= member.size <= 16 * 1024 * 1024: + raise Refused() + total += member.size + if total > LIMIT or member.size == 0: + raise Refused() + content = archive.extractfile(member).read() + if len(content) != member.size or b'AGE-SECRET-KEY-' in content: + raise Refused() + target = work / 'restored' / relative + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as dest: + dest.write(content); dest.flush(); os.fsync(dest.fileno()) + if target.read_bytes() != content: + raise Refused() + restored.add(relative) + if not required.issubset(restored): + raise Refused() + return sorted(restored) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bundle', required=True) + parser.add_argument('--bundle-sha256', required=True) + parser.add_argument('--required-file', action='append', required=True) + parser.add_argument('--receipt', required=True) + args = parser.parse_args() + if not re.fullmatch('[a-f0-9]{64}', args.bundle_sha256): raise Refused() + required = set(args.required_file) + if not required or required - ALLOWED or len(required) != len(args.required_file): raise Refused() + path = Path(args.bundle).absolute() + if any(p.is_symlink() for p in (path, *path.parents)): raise Refused() + with path.open('rb') as file: + data = file.read(LIMIT + 1) + if len(data) > LIMIT or hashlib.sha256(data).hexdigest() != args.bundle_sha256: raise Refused() + recipient = custody.public_recipient() + keyfd = custody.private_key_fd() + try: + derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], + pass_fds=(keyfd,), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10) + if derived.returncode or derived.stdout.strip() != recipient.encode(): raise Refused() + # Check the negative path with a disposable unrelated key. It never + # replaces the registered key, leaves temporary custody, or contacts IAM. + with tempfile.TemporaryDirectory(prefix='netkingdom-recovery-', dir='/tmp') as temporary: + work = Path(temporary) + disposable = work / 'wrong.key' + created = subprocess.run(['age-keygen', '-o', str(disposable)], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=10) + if created.returncode: raise Refused() + wrong = subprocess.run(['age', '-d', '-i', str(disposable)], input=data, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60, + env={'PATH': os.environ.get('PATH', os.defpath), 'HOME': temporary, + 'XDG_CONFIG_HOME': temporary}, cwd=temporary) + if wrong.returncode == 0: raise Refused() + restored = recover(data, keyfd, required, work) + if work.exists(): raise Refused() + finally: + os.close(keyfd) + receipt = {'schema': 'netkingdom.bootstrap-recovery-transport.v1', + 'observed_at': datetime.now(timezone.utc).isoformat(), 'bundle_sha256': args.bundle_sha256, + 'registered_recipient_match': True, 'wrong_key_refused': True, + 'required_files': sorted(required), 'restored_files': restored, + 'isolated_restore_readback_passed': True, 'temporary_plaintext_cleanup_verified': True, + 'secret_values_exported': False, 'cluster_contacted': False, + 'independent_custody_proven': False, 'current_service_validity_proven': False, + 'native_consumer_recovery_proven': False} + # Publish only the closed, safe receipt shape; preserve existing receipts. + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as dest: + json.dump(receipt, dest, indent=2); dest.write('\n') + print('Ciphertext transport/isolated restore checks passed. Independent custody and current service recovery remain separate owner evidence.') + + +if __name__ == '__main__': + try: + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): signal.signal(sig, custody.interrupted) + main() + except (Refused, custody.Refused, OSError, ValueError, TypeError, KeyError, tarfile.TarError, subprocess.SubprocessError): + print('ERROR: recovery transport check refused or failed; no acceptance receipt. Private diagnostics suppressed.', file=sys.stderr) + raise SystemExit(1)