#!/usr/bin/env python3 """Synthetic native reset prerequisite, with no ports, host mounts or external routes. Native Authelia and LLDAP, not a browser/OIDC/app/MFA acceptance test. Private fixture values stay in pipes or disposable container tmpfs. """ import argparse import hashlib import io import json import os import re import secrets import subprocess import tarfile import tempfile import urllib.parse import uuid from datetime import UTC, datetime from pathlib import Path AUTHELIA = 'sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54' LLDAP = 'sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d' CLIENT = 'sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd' HERE = Path(__file__).resolve().parent PROBE = (HERE / 'native_recovery/probe.py').read_text() def docker(*args, data=None, check=True): result = subprocess.run(['docker', '--context', 'default', *args], input=data, capture_output=True, timeout=90, env={'PATH': os.environ['PATH']}) if check and result.returncode: raise RuntimeError('docker_' + args[0] + '_refused') return result def archive(files): buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode='w') as tar: for name, data in files.items(): item = tarfile.TarInfo(name) item.size, item.mode, item.uid, item.gid = len(data), 0o600, 1000, 1000 tar.addfile(item, io.BytesIO(data)) return buffer.getvalue() def confined(name, network): value = json.loads(docker('inspect', name).stdout)[0] host = value['HostConfig'] if network.startswith('container:'): anchor = json.loads(docker('inspect', network.removeprefix('container:')).stdout)[0] network = 'container:' + anchor['Id'] if (host['NetworkMode'] != network or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') or host.get('Devices') or 'ALL' not in host['CapDrop'] or 'no-new-privileges' not in host['SecurityOpt'] or value['Config']['User'] != '1000:1000' or any(m['Type'] != 'tmpfs' for m in value['Mounts'])): raise RuntimeError('native_container_boundary') def container(name, image, network, env): args = ['run', '-d', '--pull=never', '--name', name, '--network', network, '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', '1000:1000', '--memory', '256m', '--cpus', '1', '--pids-limit', '128'] for path, size in [('/data', 268435456), ('/run/fixture', 1048576), ('/config', 1048576), ('/tmp', 16777216)]: args += ['--tmpfs', path + f':rw,nosuid,nodev,noexec,size={size},uid=1000,gid=1000,mode=0700'] for key, value in env.items(): args += ['--env', key + '=' + value] docker(*args, '--entrypoint', '/bin/sh', image, '-c', 'sleep 1800') confined(name, network) def probe(anchor, value): result = docker('run', '-i', '--rm', '--name', anchor + '-probe', '--pull=never', '--network', 'container:' + anchor, '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--memory', '128m', '--cpus', '1', '--pids-limit', '64', '--entrypoint', 'python', CLIENT, '-c', PROBE, data=json.dumps(value).encode(), check=False) body = json.loads(result.stdout) if result.returncode: raise RuntimeError('native_' + body.get('stage', 'probe_failed')) return body def configuration(bind_password): return { 'server': {'address': 'tcp://127.0.0.1:9091', 'disable_healthcheck': True}, 'log': {'level': 'info'}, 'ntp': {'disable_startup_check': True}, 'identity_validation': {'reset_password': {'jwt_secret': secrets.token_hex(32)}}, 'authentication_backend': {'ldap': { 'implementation': 'lldap', 'address': 'ldap://127.0.0.1:3890', 'base_dn': 'dc=example,dc=test', 'additional_users_dn': 'ou=people', 'users_filter': '(&(|({username_attribute}={input})({mail_attribute}={input}))' '(objectClass=inetOrgPerson)(!({username_attribute}=svc-*)))', 'additional_groups_dn': 'ou=groups', 'groups_filter': '(member={dn})', 'user': 'uid=svc-auth,ou=people,dc=example,dc=test', 'password': bind_password}}, 'access_control': {'default_policy': 'one_factor'}, 'session': {'secret': secrets.token_hex(32), 'cookies': [{ 'domain': 'recovery.test', 'authelia_url': 'https://auth.recovery.test'}]}, 'storage': {'encryption_key': secrets.token_hex(32), 'local': {'path': '/data/db.sqlite3'}}, 'notifier': {'filesystem': {'filename': '/data/notifications.txt'}}} def rehearse(): if not __debug__: raise RuntimeError('acceptance_assertions_must_be_enabled') context = json.loads(docker('context', 'inspect', 'default').stdout)[0] if context['Endpoints']['docker']['Host'] != 'unix:///var/run/docker.sock': raise RuntimeError('local_docker_socket_required') for image in (LLDAP, AUTHELIA, CLIENT): assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image name = 'csoc-native-reset-' + uuid.uuid4().hex[:12] auth = name + '-auth' values = {key: secrets.token_urlsafe(24) for key in [ 'admin_password', 'bind_password', 'old_password', 'new_password']} result = {} try: container(name, LLDAP, 'none', { 'LLDAP_JWT_SECRET_FILE': '/run/fixture/jwt', 'LLDAP_LDAP_USER_PASS_FILE': '/run/fixture/admin-password', 'LLDAP_LDAP_BASE_DN': 'dc=example,dc=test', 'LLDAP_LDAP_HOST': '127.0.0.1', 'LLDAP_HTTP_HOST': '127.0.0.1'}) docker('exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ 'jwt': secrets.token_hex(32).encode(), 'admin-password': values['admin_password'].encode(), 'lldap.toml': ('database_url = "sqlite:///data/users.db?mode=rwc"\nkey_seed = ' + json.dumps(secrets.token_hex(32)) + '\n').encode()})) docker('exec', '-d', name, '/bin/sh', '-c', 'exec /app/lldap run --config-file /run/fixture/lldap.toml ' '> /run/fixture/server.log 2>&1') prepared = probe(name, {**values, 'mode': 'bootstrap'}) for user, password in [('alice', values['old_password']), ('svc-auth', values['bind_password'])]: docker('exec', '-i', name, '/bin/sh', '-c', 'read -r token; read -r LLDAP_USER_PASSWORD; export LLDAP_USER_PASSWORD; ' 'exec /app/lldap_set_password --base-url http://127.0.0.1:17170 ' '--token "$token" --username ' + user, data=(prepared['admin_token'] + '\n' + password + '\n').encode()) result['scenarios'] = [] for role in ['lldap_strict_readonly', 'lldap_password_manager']: assigned = probe(name, {**values, 'mode': 'bind_role', 'role': role}) container(auth, AUTHELIA, 'container:' + name, {}) docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ 'configuration.yml': json.dumps(configuration(values['bind_password'])).encode()})) docker('exec', '-d', auth, '/bin/sh', '-c', 'exec /app/authelia --config /run/fixture/configuration.yml ' '> /run/fixture/server.log 2>&1') requested = probe(name, {**values, 'mode': 'reset_request'}) # Notification and usable handle stay private. No inbox/SMTP claim. notification = docker('exec', auth, 'cat', '/data/notifications.txt').stdout.decode() links = re.findall(r'https://auth\.recovery\.test/reset-password/step2\?[^\s<>]+', notification) assert links, 'native_challenge_missing' reset_token = urllib.parse.parse_qs(urllib.parse.urlsplit(links[0]).query)['token'][0] settled = probe(name, {**values, 'mode': 'reset_finish', 'reset_token': reset_token}) expected = role == 'lldap_password_manager' assert settled['new_password_accepted_after'] is expected assert settled['old_password_accepted_after'] is not expected assert (settled['status'] == 'reset_completed') is expected result['scenarios'].append({'bind_role': role, **requested, **settled, 'directory_group_creation_refused': assigned['directory_group_creation_refused'], 'native_reset_notification_present': True}) docker('rm', '-f', '-v', auth) except Exception: fd, diagnostic = tempfile.mkstemp(prefix='csoc-native-reset-diagnostic-', suffix='.txt') with os.fdopen(fd, 'w') as stream: for current in [name, auth]: log = docker('exec', current, 'cat', '/run/fixture/server.log', check=False) text = log.stdout.decode(errors='replace') for secret in values.values(): text = text.replace(secret, '') text = re.sub(r'[A-Za-z0-9_+/=-]{32,}', '', text) stream.write(current + '\n' + text + '\n') print('Private redacted fixture diagnostic: ' + diagnostic) raise finally: for current in [name + '-probe', auth, name]: docker('rm', '-f', '-v', current, check=False) result['cleanup_verified'] = all(not docker( 'ps', '-aq', '--filter', 'name=^/' + n + '$').stdout.strip() for n in [name + '-probe', auth, name]) if not result['cleanup_verified']: raise RuntimeError('fixture_cleanup_requires_review') if any(secret in json.dumps(result) for secret in values.values()): raise RuntimeError('fixture_value_in_receipt') return result if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) args = parser.parse_args() source_path = args.output.with_suffix('.sources.txt') if args.output.exists() or source_path.exists(): raise SystemExit('Fresh receipt and source manifest required') findings = rehearse() manifest = ''.join(str(p.relative_to(HERE.parent)) + ': ' + hashlib.sha256(p.read_bytes()).hexdigest() + '\n' for p in [Path(__file__), HERE / 'native_recovery/probe.py']).encode() receipt = {'schema': 'coulomb.native-reset-prerequisite.v1', 'data': 'synthetic', 'status': 'native_bind_role_reset_boundary_verified', 'observed_at': datetime.now(UTC).isoformat(), 'images': {'lldap': LLDAP, 'authelia': AUTHELIA, 'probe': CLIENT}, 'production_access': False, 'browser_app_or_mfa_acceptance': False, 'provider_smtp_contacted': False, 'notifier': 'private_tmpfs_file', 'no_ports_host_mounts_or_external_routes': True, 'backend_container_flags_verified_before_fixture_import': True, 'owner_runtime_or_permission_change_performed': False, 'findings': findings, 'source_manifest_sha256': hashlib.sha256(manifest).hexdigest()} with source_path.open('xb') as stream: stream.write(manifest) with args.output.open('x') as stream: json.dump(receipt, stream, indent=2) stream.write('\n') print(json.dumps(findings))