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
425 lines
23 KiB
Python
Executable file
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())
|