coulomb-social/scripts/rehearse-native-password-reset.py
tegwick b00c13fb42
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 7m57s
Rehearse native password reset against isolated directory bind roles
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-30 23:29:06 +01:00

223 lines
12 KiB
Python

#!/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, '<fixture-value>')
text = re.sub(r'[A-Za-z0-9_+/=-]{32,}', '<redacted>', 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))