"""Key transport against real filesystem/crypto and a synthetic Kubernetes API.""" import base64 import importlib.util import json import os from pathlib import Path import subprocess import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa ROOT = Path(__file__).resolve().parents[1] PI = ROOT / 'sso-mfa/k8s/privacyidea' def module(name, file): spec = importlib.util.spec_from_file_location(name, file) result = importlib.util.module_from_spec(spec); spec.loader.exec_module(result) return result @pytest.fixture def keys(): private = rsa.generate_private_key(public_exponent=65537, key_size=2048) return {'enckey': os.urandom(96), '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)} @pytest.fixture def case(tmp_path, keys): root = tmp_path / 'private'; root.mkdir(mode=0o700) (root / 'privacyidea').mkdir(mode=0o700) volume = tmp_path / 'volume'; volume.mkdir(mode=0o750) for name, value in keys.items(): (volume / name).write_bytes(value); (volume / name).chmod(0o600) binpath = tmp_path / 'bin'; binpath.mkdir() metadata = lambda name, uid: {'name': name, 'namespace': 'mfa', 'uid': uid, 'resourceVersion': '1'} mount = {'name': 'data', 'mountPath': '/etc/privacyidea'} spec = {'securityContext': {'fsGroup': 999}, 'volumes': [{'name': 'data', 'persistentVolumeClaim': {'claimName': 'privacyidea-data'}}], 'containers': [{'name': 'privacyidea', 'volumeMounts': [mount]}]} deployment = {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': {**metadata('privacyidea', 'deployment-uid'), 'generation': 1}, 'spec': {'replicas': 1, 'template': {'spec': spec}}, 'status': {'replicas': 1, 'observedGeneration': 1}} pod = {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {**metadata('privacyidea-source', 'pod-uid'), 'ownerReferences': [{'kind': 'ReplicaSet', 'name': 'privacyidea-rs', 'uid': 'rs-uid', 'controller': True}]}, 'spec': spec, 'status': {'phase': 'Running', 'containerStatuses': [{'name': 'privacyidea', 'ready': True}]}} state = {'deployment': deployment, 'pvc': {'apiVersion': 'v1', 'kind': 'PersistentVolumeClaim', 'metadata': metadata('privacyidea-data', 'pvc-uid'), 'status': {'phase': 'Bound'}}, 'pods': {'apiVersion': 'v1', 'kind': 'PodList', 'items': [pod]}, 'replicaset': {'apiVersion': 'apps/v1', 'kind': 'ReplicaSet', 'metadata': {**metadata('privacyidea-rs', 'rs-uid'), 'ownerReferences': [{'kind': 'Deployment', 'uid': 'deployment-uid', 'controller': True}]}}, 'secrets': {}} statepath = tmp_path / 'state.json'; statepath.write_text(json.dumps(state)) binary = binpath / 'kubectl' binary.write_text('''#!/usr/bin/python3 import base64,importlib.util,json,os,sys from pathlib import Path args=sys.argv[1:];p=Path(os.environ['STATE']);state=json.loads(p.read_text()) raw=sys.stdin.buffer.read() if args[0] in ('exec','create','patch') else b'' with open(os.environ['CALLS'],'a') as f:f.write(json.dumps({'args':args,'stdin':raw.decode()})+'\\n') if os.environ.get('FAIL')==args[0]: print('SYNTHETIC_SECRET_DIAGNOSTIC');print('SYNTHETIC_SECRET_DIAGNOSTIC',file=sys.stderr);sys.exit(1) if args[0]=='get': if args[1]=='secret': obj=state['secrets'].get(args[2]);print(json.dumps(obj) if obj is not None else '');sys.exit(0) if args[1]=='pod':print('privacyidea-source');sys.exit(0) print(json.dumps(state[args[1]]));sys.exit(0) if args[0]=='exec': if args[-2]=='cat':sys.stdout.buffer.write((Path(os.environ['VOLUME'])/Path(args[-1]).name).read_bytes());sys.exit(0) if 'test' in args:sys.exit(0) spec=importlib.util.spec_from_file_location('remote',Path(os.environ['SOURCE'])/'key-custody-remote.py') remote=importlib.util.module_from_spec(spec);spec.loader.exec_module(remote) assert args[-1]==(Path(os.environ['SOURCE'])/'key-custody-remote.py').read_text() request=json.loads(raw) result=remote.operate(request,Path(os.environ['VOLUME']),uid=os.getuid(),gid=os.getgid()) if os.environ.get('RACE')=='pod': state['pods']['items'][0]['metadata']['uid']='changed-pod';p.write_text(json.dumps(state)) print(json.dumps(result));sys.exit(0) if args[0]=='create': if 'generic' in args:print('{}');sys.exit(0) obj=json.loads(raw);name=obj['metadata']['name'] if name in state['secrets'] or os.environ.get('RACE')=='create':sys.exit(1) obj['metadata'].update(uid='secret-'+name,resourceVersion='1') elif args[0]=='patch': name=args[2];obj=state['secrets'][name] if os.environ.get('RACE')=='patch':sys.exit(1) for operation in json.loads(raw): parts=[v.replace('~1','/').replace('~0','~') for v in operation['path'].split('/')[1:]];parent=obj for part in parts[:-1]:parent=parent[part] if operation['op']=='test':assert parent[parts[-1]]==operation['value'] elif operation['op']=='remove':del parent[parts[-1]] else:sys.exit(2) obj['metadata']['resourceVersion']='2' elif args[0]=='apply':sys.exit(0) else:sys.exit(2) state['secrets'][name]=obj;p.write_text(json.dumps(state)) if os.environ.get('BAD_RESPONSE'):obj['data']['unexpected']='eA==' print(json.dumps(obj)) '''); binary.chmod(0o700) env = {'PATH': str(binpath) + ':' + os.environ['PATH'], 'HOME': str(tmp_path), 'STATE': str(statepath), 'CALLS': str(tmp_path/'calls'), 'VOLUME': str(volume), 'SOURCE': str(PI)} return {'root': root, 'volume': volume, 'keys': keys, 'env': env, 'statepath': statepath} def state(case): return json.loads(case['statepath'].read_text()) def save(case, value): case['statepath'].write_text(json.dumps(value)) def calls(case): p=Path(case['env']['CALLS']);return [json.loads(line) for line in p.read_text().splitlines()] if p.exists() else [] def invoke(case, action='export', *args, **extra): script = 'enckey-bootstrap.sh' if action=='export' else 'enckey-restore.sh' return subprocess.run(['bash','-x',str(PI/script),str(case['root']),*args],env={**case['env'],**extra},capture_output=True,text=True,timeout=30) def quiet(result, case): assert 'SYNTHETIC_SECRET_DIAGNOSTIC' not in result.stdout+result.stderr assert 'BEGIN RSA PRIVATE KEY' not in result.stdout+result.stderr assert base64.b64encode(case['keys']['enckey']).decode() not in result.stdout+result.stderr assert 'Traceback' not in result.stdout+result.stderr def test_export_roundtrip_and_preserves_matching_copies(case): result=invoke(case);assert result.returncode==0,result.stderr;quiet(result,case) for name,value in case['keys'].items(): p=case['root']/'privacyidea'/('pi.enc' if name=='enckey' else name) assert p.read_bytes()==value and p.stat().st_mode & 0o077==0 expected=state(case);count=len(calls(case));assert invoke(case).returncode==0 assert state(case)==expected and all(c['args'][0] in ('get','exec') for c in calls(case)[count:]) argv=json.dumps([c['args'] for c in calls(case)]) assert base64.b64encode(case['keys']['enckey']).decode() not in argv assert all(c['args'][0]!='apply' for c in calls(case)) @pytest.mark.parametrize('defect',['different','symlink','hardlink','public','fifo']) def test_export_refuses_bad_local_copy_before_secret_writes(case,defect): p=case['root']/'privacyidea/pi.enc' if defect=='fifo':os.mkfifo(p,0o600) elif defect=='symlink':p.symlink_to(case['volume']/'enckey') else: p.write_bytes(b'old-copy' if defect=='different' else case['keys']['enckey']);p.chmod(0o644 if defect=='public' else 0o600) if defect=='hardlink':os.link(p,p.with_name('alias')) result=invoke(case);assert result.returncode!=0;quiet(result,case) assert not state(case)['secrets'] and not (p.parent/'private.pem').exists() if defect=='different':assert p.read_bytes()==b'old-copy' @pytest.mark.parametrize('defect',['missing','short','link','mismatched-rsa']) def test_export_refuses_invalid_remote_keys_without_generation(case,defect,keys): p=case['volume']/'enckey' if defect=='missing':p.unlink() elif defect=='short':p.write_bytes(b'short') elif defect=='link':os.link(p,p.with_name('alias')) else: other=rsa.generate_private_key(public_exponent=65537,key_size=2048) (case['volume']/'public.pem').write_bytes(other.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)) result=invoke(case);assert result.returncode!=0;quiet(result,case) assert not list((case['root']/'privacyidea').iterdir()) and not state(case)['secrets'] assert not any('pi-manage' in c['args'] for c in calls(case)) @pytest.mark.parametrize('defect',['two-pods','not-ready','wrong-owner','wrong-pvc','pod-race']) def test_export_binds_ready_owned_pod_and_pvc(case,defect): obj=state(case);pod=obj['pods']['items'][0] if defect=='two-pods':obj['pods']['items'].append(pod) elif defect=='not-ready':pod['status']['containerStatuses'][0]['ready']=False elif defect=='wrong-owner':obj['replicaset']['metadata']['ownerReferences'][0]['uid']='other' elif defect=='wrong-pvc':pod['spec']['volumes'][0]['persistentVolumeClaim']['claimName']='other' save(case,obj) result=invoke(case,RACE='pod' if defect=='pod-race' else '');assert result.returncode!=0;quiet(result,case) assert not list((case['root']/'privacyidea').iterdir()) and not state(case)['secrets'] def test_export_failure_retains_local_recovery_copies(case): result=invoke(case,FAIL='create');assert result.returncode!=0;quiet(result,case) assert (case['root']/'privacyidea/pi.enc').read_bytes()==case['keys']['enckey'] assert not list((case['root']/'privacyidea').glob('.key-export-*')) @pytest.mark.parametrize('defect',['different','legacy-field','owner','immutable','create-race','bad-response']) def test_export_existing_backup_and_race_refusal(case,defect): if defect not in ('create-race','bad-response'): assert invoke(case).returncode==0 obj=state(case);secret=obj['secrets']['privacyidea-enckey'] if defect=='different':secret['data']['enckey']=base64.b64encode(b'other').decode() elif defect=='legacy-field':secret['data']['PI_ENCFILE']=secret['data'].pop('enckey') elif defect=='owner':secret['metadata']['ownerReferences']=[{'uid':'other-owner'}] else:secret['immutable']=True save(case,obj) before=state(case) result=invoke(case,RACE='create' if defect=='create-race' else '',BAD_RESPONSE='yes' if defect=='bad-response' else '') assert result.returncode!=0;quiet(result,case) if defect!='bad-response':assert state(case)==before def test_export_removes_annotation_conditionally_and_preserves_extra_data(case): assert invoke(case).returncode==0;obj=state(case) for secret in obj['secrets'].values(): secret['metadata']['annotations']={'kubectl.kubernetes.io/last-applied-configuration':'synthetic-copy','owner':'keep'} secret['data']['extra']='eA==' save(case,obj);assert invoke(case).returncode==0 for secret in state(case)['secrets'].values(): assert secret['metadata']['annotations']=={'owner':'keep'} and secret['data']['extra']=='eA==' patches=[json.loads(c['stdin']) for c in calls(case) if c['args'][0]=='patch'] assert len(patches)==2 and all({op['path'] for op in patch if op['op']=='test'}=={'/metadata/uid','/metadata/resourceVersion','/data'} for patch in patches) def test_explicit_first_bootstrap_creates_only_missing_audit_pair(case): for name in ('private.pem','public.pem'):(case['volume']/name).unlink() assert invoke(case).returncode!=0 result=invoke(case,'export','--initialize-audit-keys');assert result.returncode==0,result.stderr;quiet(result,case) assert (case['volume']/'enckey').read_bytes()==case['keys']['enckey'] assert invoke(case,'export','--initialize-audit-keys').returncode!=0 assert invoke(case).returncode==0 def recovery(case): assert invoke(case).returncode==0 host=module('host',PI/'key-custody.py') pod=host.render('ghcr.io/gpappsoft/privacyidea-docker@sha256:'+'a'*64,'pvc-uid') pod['metadata'].update(uid='restore-pod-uid',resourceVersion='1') pod['status']={'phase':'Running','containerStatuses':[{'name':'recovery','ready':True}]} obj=state(case);obj['pods']['items']=[pod];obj['deployment']['spec']['replicas']=0;obj['deployment']['status']['replicas']=0;save(case,obj) for name in case['keys']:(case['volume']/name).unlink() def test_restore_installs_on_actual_volume_and_retry_preserves_files(case): recovery(case);result=invoke(case,'restore','--pvc-uid','pvc-uid');assert result.returncode==0,result.stderr;quiet(result,case) for name,value in case['keys'].items():assert (case['volume']/name).read_bytes()==value before={p.name:p.stat().st_ino for p in case['volume'].iterdir()} assert invoke(case,'restore','--pvc-uid','pvc-uid').returncode==0 assert before=={p.name:p.stat().st_ino for p in case['volume'].iterdir()} @pytest.mark.parametrize('defect',['deployment-running','pod-terminating','other-pod','wrong-pvc-uid','mutable-image','no-mount','app-entrypoint','token-mount']) def test_restore_rejects_unstopped_or_wrong_target_before_key_transfer(case,defect): recovery(case);obj=state(case);pod=obj['pods']['items'][0] if defect=='deployment-running':obj['deployment']['spec']['replicas']=1 elif defect=='pod-terminating':pod['metadata']['deletionTimestamp']='now' elif defect=='other-pod':obj['pods']['items'].append(pod) elif defect=='wrong-pvc-uid':obj['pvc']['metadata']['uid']='replacement-pvc' elif defect=='mutable-image':pod['spec']['containers'][0]['image']='image:latest' elif defect=='no-mount':pod['spec']['containers'][0]['volumeMounts']=[] elif defect=='app-entrypoint':pod['spec']['containers'][0]['command']=['pi-manage','run'] else:pod['spec']['automountServiceAccountToken']=True save(case,obj);count=len(calls(case));result=invoke(case,'restore','--pvc-uid','pvc-uid') assert result.returncode!=0;quiet(result,case) assert not list(case['volume'].iterdir()) and all(c['args'][0]=='get' for c in calls(case)[count:]) def test_restore_refuses_partial_mismatching_state_without_overwrite(case): recovery(case);p=case['volume']/'private.pem';p.write_bytes(b'other');p.chmod(0o600) result=invoke(case,'restore','--pvc-uid','pvc-uid');assert result.returncode!=0;quiet(result,case) assert p.read_bytes()==b'other' and not (case['volume']/'enckey').exists() def test_restored_key_decrypts_existing_native_privacyidea_values(case): pytest.importorskip('privacyidea') from privacyidea.lib.security.default import DefaultSecurityModule source=DefaultSecurityModule({'file':str(case['volume']/'enckey')}) ciphertext=source.encrypt_pin('synthetic-token-pin') password=source.encrypt_password('synthetic-configuration-value') from privacyidea.lib.crypto import Sign signer=Sign((case['volume']/'private.pem').read_bytes(),(case['volume']/'public.pem').read_bytes()) signature=signer.sign('synthetic-audit-record') recovery(case);assert invoke(case,'restore','--pvc-uid','pvc-uid').returncode==0 restored=DefaultSecurityModule({'file':str(case['volume']/'enckey')}) assert restored.decrypt_pin(ciphertext)=='synthetic-token-pin' assert restored.decrypt_password(password)=='synthetic-configuration-value' recovered_signer=Sign((case['volume']/'private.pem').read_bytes(),(case['volume']/'public.pem').read_bytes()) assert recovered_signer.verify('synthetic-audit-record',signature) assert signer.verify('synthetic-new-audit-record',recovered_signer.sign('synthetic-new-audit-record')) def test_remote_partial_restore_keeps_keys_and_retries_without_replacement(tmp_path, keys, monkeypatch): remote=module('partial_remote',PI/'key-custody-remote.py') real_link=remote.os.link def interrupted(source,target,**kwargs): if target=='public.pem':raise OSError('synthetic-write-failure') return real_link(source,target,**kwargs) request={'action':'restore','keys':{k:base64.b64encode(v).decode() for k,v in keys.items()}} monkeypatch.setattr(remote.os,'link',interrupted) with pytest.raises(OSError):remote.operate(request,tmp_path,uid=os.getuid(),gid=os.getgid()) assert (tmp_path/'enckey').read_bytes()==keys['enckey'] assert (tmp_path/'private.pem').read_bytes()==keys['private.pem'] assert not list(tmp_path.glob('.key-custody-*')) inodes={p.name:p.stat().st_ino for p in tmp_path.iterdir()} monkeypatch.setattr(remote.os,'link',real_link) assert remote.operate(request,tmp_path,uid=os.getuid(),gid=os.getgid())=={'result':'restored'} assert all((tmp_path/name).stat().st_ino==inode for name,inode in inodes.items()) def test_remote_partial_audit_initialization_cannot_generate_another_pair(tmp_path, keys, monkeypatch): remote=module('init_remote',PI/'key-custody-remote.py') p=tmp_path/'enckey';p.write_bytes(keys['enckey']);p.chmod(0o600) real_link=remote.os.link def interrupted(source,target,**kwargs): if target=='public.pem':raise OSError('synthetic-write-failure') return real_link(source,target,**kwargs) monkeypatch.setattr(remote.os,'link',interrupted) with pytest.raises(OSError):remote.operate({'action':'initialize-audit'},tmp_path,uid=os.getuid(),gid=os.getgid()) before=(tmp_path/'private.pem').read_bytes() monkeypatch.setattr(remote.os,'link',real_link) with pytest.raises(remote.Refused):remote.operate({'action':'initialize-audit'},tmp_path,uid=os.getuid(),gid=os.getgid()) assert (tmp_path/'private.pem').read_bytes()==before def test_unknown_argument_is_not_echoed(case): result=invoke(case,'export','--SYNTHETIC_SECRET_DIAGNOSTIC') assert result.returncode!=0;quiet(result,case);assert not calls(case) def test_kubectl_generic_list_output_is_accepted(case): obj=state(case);obj['pods']['kind']='List';save(case,obj) assert invoke(case).returncode==0