#!/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())