net-kingdom/tools/bootstrap-recovery/isolated-authelia.py
tegwick 006dc23fdf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0049-T41: isolated Authelia restore stage, fixture-tested
isolated-authelia.py runs restored LLDAP as the network-none anchor with
Authelia joining its namespace. Checks the original config, storage key,
issuer key, KeyCape's client secret and existing-user first factor, with
wrong-password and wrong-client-secret refusal. Confinement rewrites: LDAP
to loopback, file notifier, NTP check off, healthcheck file off. Imports
isolated-lldap.py unedited; the T38-pinned KeyCape sources are untouched.
Native fixture tests on the pinned images pass (9). T17 now awaits the
founder-attended run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 328925@bnt-lap001
Assistant-Session: 14b795fd-c632-4c01-8d28-e7e490c7b0ed
2026-10-24 17:14:52 +02:00

425 lines
23 KiB
Python
Executable file

#!/usr/bin/env python3
"""Owner-run native Authelia restore against the restored LLDAP directory.
Private inputs remain in the owner process/pipe. Decrypted staging uses Linux
tmpfs. Restored LLDAP is the network-none anchor; Authelia and the probe join
its namespace and use loopback only. No ports, host mounts or production APIs.
"""
import argparse
import base64
from datetime import datetime, timezone
import hashlib
import importlib.util
import json
import os
from pathlib import Path, PurePosixPath
import re
import signal
import subprocess
import tempfile
import time
from urllib.parse import urlsplit
import uuid
import yaml
HERE = Path(__file__).resolve().parent
def load(name):
spec = importlib.util.spec_from_file_location(name.replace('-', '_'), HERE / (name + '.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# Reused unedited: isolated-lldap.py is in the pinned KeyCape attempt sources.
lldap = load('isolated-lldap')
verify, offline, bundle = lldap.verify, lldap.offline, lldap.bundle
command, archive_files = lldap.command, lldap.archive_files
IMAGE = 'docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54'
LDAP_URL = 'ldap://127.0.0.1:3890'
NOTIFICATIONS = 'notification.txt'
def preflight():
target = lldap.preflight()
image_id = command(['docker', 'image', 'inspect', '--format', '{{.Id}}', IMAGE]).decode().strip()
target['images'].append({'reference': IMAGE, 'image_id': image_id})
return target
def mounted(spec, volumes, predicate):
found = [m for m in spec.get('volumeMounts', []) if predicate(volumes.get(m['name'], {}))]
if len(found) != 1:
raise bundle.Refused()
return found[0]
def configuration(root):
"""Assemble the archived Authelia runtime with only confinement rewrites."""
deployment = bundle.resource(root, 'sso', 'deployment', 'authelia')
pod = deployment['spec']['template']['spec']
main = next(c for c in pod['containers'] if c['name'] == 'authelia')
volumes = {v['name']: v for v in pod.get('volumes', [])}
secrets_mount = mounted(main, volumes, lambda v: v.get('secret', {}).get('secretName') == 'authelia-secrets')
config_mount = mounted(main, volumes, lambda v: v.get('configMap', {}).get('name') == 'authelia-config')
data_mount = mounted(main, volumes, lambda v: 'persistentVolumeClaim' in v)
smtp_paths = [m['mountPath'] for m in main.get('volumeMounts', [])
if volumes.get(m['name'], {}).get('secret', {}).get('secretName') == 'authelia-smtp']
secret_root = PurePosixPath(secrets_mount['mountPath'])
data_root = PurePosixPath(data_mount['mountPath'])
if not secret_root.is_absolute() or not data_root.is_absolute() or volumes[secrets_mount['name']]['secret'].get('items'):
raise bundle.Refused()
raw = bundle.resource(root, 'sso', 'secret', 'authelia-secrets')['data']
secrets = {}
for key, value in raw.items():
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', key):
raise bundle.Refused()
decoded = base64.b64decode(value, validate=True)
if not decoded:
raise bundle.Refused()
secrets[str(secret_root / key)] = decoded
env = {}
for entry in main.get('env', []):
if 'value' not in entry:
raise bundle.Refused()
name, value = entry['name'], entry['value']
if any(value.startswith(p.rstrip('/') + '/') for p in smtp_paths):
continue # Notifier credentials are never imported.
if name.endswith('_FILE') and value not in secrets:
raise bundle.Refused()
if not re.fullmatch(r'[A-Z][A-Z0-9_]{0,127}', name) or '\n' in value:
raise bundle.Refused()
env[name] = value
issuer_path = env.get('AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE')
if not issuer_path or 'AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE' not in env:
raise bundle.Refused()
configmap = bundle.resource(root, 'sso', 'configmap', 'authelia-config')['data']
config = yaml.safe_load(configmap[config_mount.get('subPath', 'configuration.yml')])
ldap = config['authentication_backend']['ldap']
if ('address' in ldap) == ('url' in ldap):
raise bundle.Refused()
ldap['address' if 'address' in ldap else 'url'] = LDAP_URL
config['notifier'] = {'filesystem': {'filename': str(data_root / NOTIFICATIONS)}}
# The NTP startup check is an outbound UDP call; it cannot succeed without a network.
config['ntp'] = {**(config.get('ntp') or {}), 'disable_startup_check': True}
store = PurePosixPath(config['storage']['local']['path'])
if store.parent != data_root or not (root / 'data/authelia' / store.name).is_file():
raise bundle.Refused()
server = config.setdefault('server', {})
# The healthcheck helper writes /app/.healthcheck.env before any provider check;
# the root filesystem stays read-only and the probe uses /api/health instead.
server['disable_healthcheck'] = True
if 'address' in server:
port = urlsplit(server['address']).port
else:
port = server.get('port', 9091)
if not isinstance(port, int) or not 0 < port < 65536:
raise bundle.Refused()
clients = config['identity_providers']['oidc']['clients']
keycape = yaml.safe_load(base64.b64decode(
bundle.resource(root, 'sso', 'secret', 'keycape-config')['data']['config.yaml'], validate=True))['authelia']
client = [c for c in clients if c.get('client_id', c.get('id')) == keycape['clientId']]
secret = keycape.get('clientSecret')
portal = urlsplit(keycape.get('browserBaseURL', ''))
if (len(client) != 1 or not isinstance(secret, str) or not secret or portal.scheme != 'https'
or not portal.hostname or not client[0].get('redirect_uris')):
raise bundle.Refused()
return {'config': yaml.safe_dump(config, sort_keys=False).encode(), 'config_path': config_mount['mountPath'],
'secrets': secrets, 'env': env, 'data_root': str(data_root), 'secret_root': str(secret_root),
'port': port, 'host': portal.hostname, 'issuer_key': secrets[issuer_path],
'client_id': keycape['clientId'], 'client_secret': secret,
'redirect_uri': client[0]['redirect_uris'][0]}
def public_jwk(pem):
from cryptography.hazmat.primitives import serialization
key = serialization.load_pem_private_key(pem, password=None).public_key()
numbers = key.public_numbers()
def encode(value):
raw = value.to_bytes((value.bit_length() + 7) // 8, 'big')
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
jwk = {'e': encode(numbers.e), 'kty': 'RSA', 'n': encode(numbers.n)}
thumbprint = base64.urlsafe_b64encode(hashlib.sha256(
json.dumps(jwk, separators=(',', ':'), sort_keys=True).encode()).digest()).rstrip(b'=').decode()
return jwk, thumbprint
PROBE = r'''
import base64,json,sys,time,urllib.error,urllib.parse,urllib.request
from pathlib import Path
result={'status':'failed'}
stage='network_isolation'
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self,*args,**kwargs):return None
opener=urllib.request.build_opener(NoRedirect,urllib.request.ProxyHandler({}))
try:
assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'}
assert len(Path('/proc/net/route').read_text().splitlines())==1
v=json.load(sys.stdin)
base='http://127.0.0.1:%d'%v['port']
head={'X-Forwarded-Proto':'https','X-Forwarded-Host':v['host'],'X-Forwarded-For':'127.0.0.1'}
def call(path,data=None,headers=None,form=False):
h=dict(head,**(headers or {}))
if data is not None:
if form:body=urllib.parse.urlencode(data).encode();h['Content-Type']='application/x-www-form-urlencoded'
else:body=json.dumps(data).encode();h['Content-Type']='application/json'
else:body=None
try:
with opener.open(urllib.request.Request(base+path,data=body,headers=h),timeout=10) as r:return r.status,r.read()
except urllib.error.HTTPError as e:return e.code,e.read()
stage='authelia_startup'
for _ in range(120):
try:
if call('/api/health')[0]==200:break
except OSError:pass
time.sleep(.25)
else:raise RuntimeError()
stage='issuer_jwks'
code,body=call('/jwks.json')
assert code==200
keys=[{'n':k['n'],'e':k['e']} for k in json.loads(body)['keys'] if k.get('kty')=='RSA' and k.get('use','sig')=='sig']
stage='existing_user_first_factor'
code,body=call('/api/firstfactor',{'username':v['user'],'password':v['password'],'keepMeLoggedIn':False})
assert code==200 and json.loads(body).get('status')=='OK'
stage='wrong_password_first_factor'
code,body=call('/api/firstfactor',{'username':v['user'],'password':v['wrong_password'],'keepMeLoggedIn':False})
assert code==401
def token(secret):
auth=base64.b64encode((urllib.parse.quote(v['client_id'],safe='')+':'+urllib.parse.quote(secret,safe='')).encode()).decode()
code,body=call('/api/oidc/token',{'grant_type':'authorization_code','code':'nk49-invalid-'+v['nonce'],
'redirect_uri':v['redirect_uri']},{'Authorization':'Basic '+auth},form=True)
return code,json.loads(body)
stage='client_secret_verifier'
code,body=token(v['client_secret'])
assert code==400 and body.get('error')=='invalid_grant' and 'access_token' not in body
stage='wrong_client_secret'
code,body=token(v['wrong_secret'])
assert code==401 and body.get('error')=='invalid_client' and 'access_token' not in body
result={'status':'verified','jwks_public_keys':keys,'existing_user_first_factor':True,
'wrong_password_refused':True,'archived_client_secret_accepted':True,
'wrong_client_secret_refused':True,'grant_issued':False,
'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 contained(name, network, user='1000:1000'):
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'] != 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 guard['user'] != user
or any(mount['Type'] != 'tmpfs' for mount in guard['mounts'])):
raise bundle.Refused()
def tmpfs(path, size):
return ['--tmpfs', path + ':rw,nosuid,nodev,noexec,size=' + str(size) + ',uid=1000,gid=1000,mode=0700']
def stopped(name, pidfile):
command(['docker', 'exec', name, '/bin/sh', '-c', 'kill -TERM "$(cat ' + pidfile + ')"'])
for _ in range(100):
running = subprocess.run(['docker', 'exec', name, '/bin/sh', '-c', 'kill -0 "$(cat ' + pidfile + ')"'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
if running.returncode:
return
time.sleep(.1)
raise bundle.Refused()
def restore_authelia(root, user, password, receipt):
lldap_config, jwt, admin_password, _ = lldap.configuration(root)
runtime = configuration(root)
expected_jwk, thumbprint = public_jwk(runtime['issuer_key'])
stem = 'nk49-authelia-' + uuid.uuid4().hex
anchor, service, client = stem + '-lldap', stem + '-service', stem + '-client'
stage = 'directory_launch'
try:
command(['docker', 'run', '--detach', '--pull=never', '--name', anchor,
'--network', 'none', '--read-only', '--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
'--memory', '256m', '--cpus', '1', '--pids-limit', '128',
*tmpfs('/data', 268435456), *tmpfs('/run/recovery', 1048576), *tmpfs('/tmp', 16777216),
'--env', 'LLDAP_JWT_SECRET_FILE=/run/recovery/jwt',
'--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/recovery/admin-password',
'--entrypoint', '/bin/sh', lldap.IMAGE, '-c', 'sleep 1800'])
contained(anchor, 'none')
anchor_id = command(['docker', 'container', 'inspect', anchor, '--format', '{{.Id}}']).decode().strip()
stage = 'authelia_launch'
# Environment carries file paths only; values arrive by tar into tmpfs.
args = ['docker', 'run', '--detach', '--pull=never', '--name', service,
'--network', 'container:' + anchor_id, '--read-only', '--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
'--memory', '512m', '--cpus', '1', '--pids-limit', '128',
*tmpfs(runtime['secret_root'], 1048576), *tmpfs(runtime['data_root'], 268435456),
*tmpfs(str(PurePosixPath(runtime['config_path']).parent), 1048576), *tmpfs('/tmp', 16777216)]
for name, value in runtime['env'].items():
args += ['--env', name + '=' + value]
command(args + ['--entrypoint', '/bin/sh', IMAGE, '-c', 'sleep 1800'])
contained(service, 'container:' + anchor_id)
receipt['container_isolation_verified_before_private_import'] = True
stage = 'private_import'
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', anchor, 'tar', '-xf', '-', '-C', '/data'], input=archive_files(payload))
command(['docker', 'exec', '-i', anchor, 'tar', '-xf', '-', '-C', '/run/recovery'],
input=archive_files({'lldap.toml': lldap_config, 'jwt': jwt, 'admin-password': admin_password}))
stored = {p.relative_to(root / 'data/authelia').as_posix(): p.read_bytes()
for p in (root / 'data/authelia').rglob('*') if p.is_file()}
command(['docker', 'exec', '-i', service, 'tar', '-xf', '-', '-C', runtime['data_root']],
input=archive_files(stored))
for directory, files in ((runtime['secret_root'], {k: v for k, v in runtime['secrets'].items()}),
(str(PurePosixPath(runtime['config_path']).parent),
{runtime['config_path']: runtime['config']})):
relative = {str(PurePosixPath(k).relative_to(directory)): v for k, v in files.items()}
command(['docker', 'exec', '-i', service, 'tar', '-xf', '-', '-C', directory], input=archive_files(relative))
stage = 'configuration_validation'
config = ['--config', runtime['config_path']]
command(['docker', 'exec', service, '/app/authelia', 'validate-config', *config])
receipt['original_configuration_validated'] = True
stage = 'storage_decryption'
checked = command(['docker', 'exec', service, '/app/authelia', 'storage', 'encryption', 'check', *config])
if b'SUCCESS' not in checked or b'FAILURE' in checked:
raise bundle.Refused()
receipt['storage_encryption_key_verified'] = True
stage = 'directory_startup'
command(['docker', 'exec', '--detach', anchor, '/bin/sh', '-c',
'echo $$ > /run/recovery/server.pid; exec /app/lldap run --config-file /run/recovery/lldap.toml > /dev/null 2>&1'])
# Authelia's startup check binds to LDAP once and exits if it is not up yet.
command(['docker', 'exec', service, '/bin/sh', '-c',
'i=0; until nc -w 1 127.0.0.1 3890 </dev/null >/dev/null 2>&1; do '
'i=$((i+1)); [ "$i" -ge 120 ] && exit 1; sleep 0.25; done'], timeout=60)
stage = 'service_startup'
command(['docker', 'exec', '--detach', service, '/bin/sh', '-c',
'echo $$ > /tmp/authelia.pid; exec /app/authelia ' + ' '.join(config) + ' > /dev/null 2>&1'])
stage = 'native_probe'
raw = command(['docker', 'run', '--interactive', '--rm', '--name', client, '--pull=never',
'--network', 'container:' + anchor_id, '--read-only', '--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges', '--memory', '256m', '--cpus', '1',
'--pids-limit', '64', '--entrypoint', 'python3', lldap.CLIENT, '-c', PROBE],
input=json.dumps({'user': user, 'password': password, 'port': runtime['port'],
'host': runtime['host'], 'client_id': runtime['client_id'],
'client_secret': runtime['client_secret'],
'redirect_uri': runtime['redirect_uri'], 'nonce': uuid.uuid4().hex,
'wrong_password': 'nk49-wrong-' + uuid.uuid4().hex,
'wrong_secret': 'nk49-wrong-' + uuid.uuid4().hex}).encode(), timeout=180)
result = json.loads(raw)
keys = result.pop('jwks_public_keys', [])
receipt.update(result)
if result.get('status') != 'verified':
stage = result.get('failed_stage', stage)
raise bundle.Refused()
if {'n': expected_jwk['n'], 'e': expected_jwk['e']} not in keys:
stage = 'issuer_jwks'
raise bundle.Refused()
receipt.update(issuer_key_matches_archive=True, issuer_jwk_thumbprint_sha256=thumbprint)
stage = 'service_shutdown'
stopped(service, '/tmp/authelia.pid')
stopped(anchor, '/run/recovery/server.pid')
sent = subprocess.run(['docker', 'exec', service, 'test', '-s', runtime['data_root'] + '/' + NOTIFICATIONS],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0
receipt.update(notifications_confined_to_tmpfs_file=True, notification_written=sent)
except Exception:
receipt['status'] = 'failed'
receipt.setdefault('failed_stage', stage)
raise
finally:
for owned in (client, service, anchor):
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-authelia-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(),
'reused_source_sha256': {name: hashlib.sha256((HERE / name).read_bytes()).hexdigest()
for name in ('isolated-lldap.py', 'identity-bundle.py', 'verify-identity.py',
'retrieve-identity-offline.py')}}
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 Authelia 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_authelia_check'
restore_authelia(restored, args.user, password, result)
password = None
if work.exists():
raise bundle.Refused()
result.update(status='isolated_authelia_verified', temporary_plaintext_cleanup_verified=True)
except (Exception, KeyboardInterrupt):
result.update(status='failed', error='isolated_authelia_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_authelia_verified'
print('OK: restored Authelia config, storage key, issuer key, client verifier, first factor and cleanup passed.'
if success else 'ERROR: isolated Authelia 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())