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
This commit is contained in:
parent
4719970abb
commit
006dc23fdf
7 changed files with 783 additions and 3 deletions
|
|
@ -29,6 +29,15 @@ grant. Actual-image synthetic tests and the real owner backup-recovery run
|
|||
passed on 2026-10-16; T16 is done. See the packet and actual LLDAP receipt for
|
||||
the exact target, resource limits, accepted scope and repeat-drill command.
|
||||
|
||||
`run-isolated-authelia.sh USER TRANSFER_RECEIPT` is the Authelia stage (T17,
|
||||
prepared under T41). It asks for the same two private inputs. `isolated-authelia.py`
|
||||
runs restored LLDAP as the network-none anchor with Authelia joining its
|
||||
namespace, and checks the original config, storage encryption key, issuer
|
||||
key, KeyCape's `keycape` client secret and existing-user first factor, with
|
||||
wrong-password and wrong-client-secret refusal. Notifier, NTP and healthcheck
|
||||
writes are confined; nothing leaves loopback. It imports `isolated-lldap.py`
|
||||
unedited. Synthetic native tests pass; the actual owner run is still open.
|
||||
|
||||
`run-isolated-privacyidea.sh TRANSFER_RECEIPT` is the token-material/audit recovery step.
|
||||
It asks only for the existing backup age key. `isolated-privacyidea.py` restores
|
||||
the linked dump into a loopback-only PostgreSQL/container namespace, reads
|
||||
|
|
|
|||
425
tools/bootstrap-recovery/isolated-authelia.py
Executable file
425
tools/bootstrap-recovery/isolated-authelia.py
Executable file
|
|
@ -0,0 +1,425 @@
|
|||
#!/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())
|
||||
37
tools/bootstrap-recovery/run-isolated-authelia.sh
Executable file
37
tools/bootstrap-recovery/run-isolated-authelia.sh
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env bash
|
||||
# Owner-run existing-user check. No private key/password belongs in arguments.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd -- "$ROOT"
|
||||
umask 077
|
||||
[[ $# == 2 ]] || { printf '%s\n' 'Usage: run-isolated-authelia.sh USER VERIFIED_TRANSFER_RECEIPT'; exit 1; }
|
||||
[[ "$1" =~ ^[A-Za-z0-9_.-]{1,128}$ ]] || { printf '%s\n' 'ERROR: invalid username.'; exit 1; }
|
||||
[[ -f "$2" && ! -L "$2" ]] || { printf '%s\n' 'ERROR: provide the existing verified transfer receipt.'; exit 1; }
|
||||
[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; }
|
||||
for tool in python3 age age-keygen openssl docker; do
|
||||
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
|
||||
done
|
||||
python3 - <<'PY'
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
p=Path('tools/bootstrap-recovery/isolated-authelia.py')
|
||||
spec=importlib.util.spec_from_file_location('isolated',p)
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
try:
|
||||
m.preflight()
|
||||
except Exception:
|
||||
raise SystemExit('ERROR: exact local Docker target, pinned images or tmpfs preflight failed; no key requested.')
|
||||
print('OK: exact bnt-lap001 recovery engine, pinned images and tmpfs available.')
|
||||
PY
|
||||
NK49_AUTHELIA_RUN="$(mktemp -d /tmp/nk49-isolated-authelia-XXXXXXXX)"
|
||||
printf 'Safe evidence directory: %s\n' "$NK49_AUTHELIA_RUN"
|
||||
printf '%s\n' 'This restores LLDAP and Authelia into disposable containers sharing one network-free namespace: no external network, ports or host mounts.'
|
||||
printf '%s\n' 'Enter the railiance-backup age key, then the existing account password that was valid at backup capture.'
|
||||
printf '%s\n' 'Checks: original config, storage key, issuer key, KeyCape client verifier, first factor; wrong password and client secret refused. Notifications go to a tmpfs file. No production change.'
|
||||
bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \
|
||||
--prompt -- python3 tools/bootstrap-recovery/isolated-authelia.py \
|
||||
--user "$1" --transfer-receipt "$2" --receipt "$NK49_AUTHELIA_RUN/recovery.json"
|
||||
printf 'Safe receipt: %s/recovery.json\n' "$NK49_AUTHELIA_RUN"
|
||||
printf '%s\n' 'Return only the outcome and evidence directory. KeyCape consumer, MFA and audit acceptance remain separate.'
|
||||
Loading…
Add table
Add a link
Reference in a new issue